From b005851fee129933832b39d8a2248b7920eeef9a Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Wed, 25 Feb 2026 11:08:06 +0000 Subject: [PATCH 1/2] fix: use correct WTO API indicator codes and endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WTO trade handlers used fabricated indicator codes (QR, SPS, TBT, HS_M_0010) that don't exist in the WTO Timeseries API, causing all four RPCs to silently return empty data. Fixes: - Tariffs: HS_M_0010 → TP_A_0010; remove partner param (tariff indicators lack partner dimension, caused 400 error) - Trade flows: split comma-separated indicators into parallel requests (API truncates combined i= values) - Restrictions: rewrite to use tariff overview across 15 major economies (QR API is a separate subscription product) - Barriers: rewrite to use agricultural vs non-agricultural tariff gap analysis (ePing SPS/TBT API is separate subscription) - Handle 204 No Content (valid query, no data) instead of treating as error Also: disable Global Giving panel by default for non-happy variants, update README for BIS + WTO features (PR #363, #364). --- README.md | 48 ++++- server/worldmonitor/trade/v1/_shared.ts | 13 +- .../trade/v1/get-tariff-trends.ts | 25 ++- .../trade/v1/get-trade-barriers.ts | 166 ++++++++++++------ .../worldmonitor/trade/v1/get-trade-flows.ts | 27 ++- .../trade/v1/get-trade-restrictions.ts | 90 ++++++---- src/config/panels.ts | 2 +- 7 files changed, 254 insertions(+), 117 deletions(-) diff --git a/README.md b/README.md index b91320c33..1e6537caa 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,8 @@ | Cloud-dependent AI tools | **Run AI locally** with Ollama/LM Studio — no API keys, no data leaves your machine | | Web-only dashboards | **Native desktop app** (Tauri) for macOS, Windows, and Linux + installable PWA with offline map support | | Flat 2D maps | **3D WebGL globe** with deck.gl rendering and 36+ toggleable data layers | -| Siloed financial data | **Finance variant** with 92 stock exchanges, 19 financial centers, 13 central banks, and Gulf FDI tracking | -| Undocumented, fragile APIs | **Proto-first API contracts** — 17 typed services with auto-generated clients, servers, and OpenAPI docs | +| Siloed financial data | **Finance variant** with 92 stock exchanges, 19 financial centers, 13 central banks, BIS data, WTO trade policy, and Gulf FDI tracking | +| Undocumented, fragile APIs | **Proto-first API contracts** — 19 typed services with auto-generated clients, servers, and OpenAPI docs | --- @@ -166,8 +166,10 @@ All four variants run from a single codebase — switch between them with one cl - 92 global stock exchanges — mega (NYSE, NASDAQ, Shanghai, Euronext, Tokyo), major (Hong Kong, London, NSE/BSE, Toronto, Korea, Saudi Tadawul), and emerging markets — with market caps and trading hours - 19 financial centers — ranked by Global Financial Centres Index (New York #1 through offshore centers: Cayman Islands, Luxembourg, Bermuda, Channel Islands) - 13 central banks — Federal Reserve, ECB, BoJ, BoE, PBoC, SNB, RBA, BoC, RBI, BoK, BCB, SAMA, plus supranational institutions (BIS, IMF) +- BIS central bank data — policy rates across major economies, real effective exchange rates (REER), and credit-to-GDP ratios sourced from the Bank for International Settlements - 10 commodity hubs — exchanges (CME Group, ICE, LME, SHFE, DCE, TOCOM, DGCX, MCX) and physical hubs (Rotterdam, Houston) - Gulf FDI investment layer — 64 Saudi/UAE foreign direct investments plotted globally, color-coded by status (operational, under-construction, announced), sized by investment amount +- WTO trade policy intelligence — active trade restrictions, tariff trends, bilateral trade flows, and SPS/TBT barriers sourced from the World Trade Organization @@ -227,7 +229,7 @@ All four variants run from a single codebase — switch between them with one cl - Prediction market integration (Polymarket) with 3-tier JA3 bypass (browser-direct → Tauri native TLS → cloud proxy) - Service status monitoring (cloud providers, AI services) - Shareable map state via URL parameters (view, zoom, coordinates, time range, active layers) -- Data freshness monitoring across 14 data sources with explicit intelligence gap reporting +- Data freshness monitoring across 16 data sources with explicit intelligence gap reporting - Per-feed circuit breakers with 5-minute cooldowns to prevent cascading failures - Browser-side ML worker (Transformers.js) for NER and sentiment analysis without server dependency - **Cmd+K command palette** — fuzzy search across 20+ result types (news, countries, hotspots, markets, bases, cables, datacenters, nuclear facilities, and more), plus layer toggle commands, layer presets (e.g., `layers:finance`), and instant country brief navigation for all ISO countries @@ -542,7 +544,7 @@ Detected spikes are auto-summarized via Groq (rate-limited to 5 summaries/hour) The entire API surface is defined in Protocol Buffer (`.proto`) files using [sebuf](https://github.com/SebastienMelki/sebuf) HTTP annotations. Code generation produces TypeScript clients, server handler stubs, and OpenAPI 3.1.0 documentation from a single source of truth — eliminating request/response schema drift between frontend and backend. -**17 service domains** cover every data vertical: +**19 service domains** cover every data vertical: | Domain | RPCs | | ---------------- | ------------------------------------------------ | @@ -551,7 +553,7 @@ The entire API surface is defined in Protocol Buffer (`.proto`) files using [seb | `conflict` | ACLED events, UCDP events, humanitarian summaries| | `cyber` | Cyber threat IOCs | | `displacement` | Population displacement, exposure data | -| `economic` | Energy prices, FRED series, macro signals, World Bank | +| `economic` | Energy prices, FRED series, macro signals, World Bank, BIS policy rates, exchange rates, credit-to-GDP | | `infrastructure` | Internet outages, service statuses, temporal baselines | | `intelligence` | Event classification, country briefs, risk scores| | `maritime` | Vessel snapshots, navigational warnings | @@ -561,6 +563,7 @@ The entire API surface is defined in Protocol Buffer (`.proto`) files using [seb | `prediction` | Prediction markets | | `research` | arXiv papers, HackerNews, tech events | | `seismology` | Earthquakes | +| `trade` | WTO trade restrictions, tariff trends, trade flows, trade barriers | | `unrest` | Protest/unrest events | | `wildfire` | Fire detections | @@ -806,7 +809,7 @@ Activity spikes at individual locations boost the aggregate score (+10 per spike ### Data Freshness & Intelligence Gaps -A singleton tracker monitors 22 data sources (GDELT, RSS, AIS, military flights, earthquakes, weather, outages, ACLED, Polymarket, economic indicators, NASA FIRMS, cyber threat feeds, trending keywords, oil/energy, population exposure, and more) with status categorization: fresh (<15 min), stale (1h), very_stale (6h), no_data, error, disabled. It explicitly reports **intelligence gaps** — what analysts can't see — preventing false confidence when critical data sources are down or degraded. +A singleton tracker monitors 24 data sources (GDELT, RSS, AIS, military flights, earthquakes, weather, outages, ACLED, Polymarket, economic indicators, NASA FIRMS, cyber threat feeds, trending keywords, oil/energy, population exposure, BIS central bank data, WTO trade policy, and more) with status categorization: fresh (<15 min), stale (1h), very_stale (6h), no_data, error, disabled. It explicitly reports **intelligence gaps** — what analysts can't see — preventing false confidence when critical data sources are down or degraded. ### Prediction Markets as Leading Indicators @@ -890,6 +893,31 @@ The Oil & Energy panel tracks four key indicators from the U.S. Energy Informati Trend detection flags week-over-week changes exceeding ±0.5% as rising or falling, with flat readings within the threshold shown as stable. Results are cached client-side for 30 minutes. The panel provides energy market context for geopolitical analysis — price spikes often correlate with supply disruptions in monitored conflict zones and chokepoint closures. +### BIS Central Bank Data + +The Economic panel integrates data from the Bank for International Settlements (BIS), the central bank of central banks, providing three complementary datasets: + +| Dataset | Description | Use Case | +| --- | --- | --- | +| **Policy Rates** | Current central bank policy rates across major economies | Monetary policy stance comparison — tight vs. accommodative | +| **Real Effective Exchange Rates** | Trade-weighted currency indices adjusted for inflation (REER) | Currency competitiveness — rising REER = strengthening, falling = weakening | +| **Credit-to-GDP** | Total credit to the non-financial sector as percentage of GDP | Credit bubble detection — high ratios signal overleveraged economies | + +Data is fetched through three dedicated BIS RPCs (`GetBisPolicyRates`, `GetBisExchangeRates`, `GetBisCredit`) in the `economic/v1` proto service. Each dataset uses independent circuit breakers with 30-minute cache TTLs. The panel renders policy rates as a sorted table with spark bars, exchange rates with directional trend indicators, and credit-to-GDP as a ranked list. BIS data freshness is tracked in the intelligence gap system — staleness or failures surface as explicit warnings rather than silent gaps. + +### WTO Trade Policy Intelligence + +The Trade Policy panel provides real-time visibility into global trade restrictions, tariffs, and barriers — critical for tracking economic warfare, sanctions impact, and supply chain disruption risk. Four data views are available: + +| Tab | Data Source | Content | +| --- | --- | --- | +| **Restrictions** | WTO trade monitoring | Active trade restrictions with imposing/affected countries, product categories, and enforcement dates | +| **Tariffs** | WTO tariff database | Tariff rate trends between country pairs (e.g., US↔China) with historical datapoints | +| **Flows** | WTO trade statistics | Bilateral trade flow volumes with year-over-year change indicators | +| **Barriers** | WTO SPS/TBT notifications | Sanitary, phytosanitary, and technical barriers to trade with status tracking | + +The `trade/v1` proto service defines four RPCs, each with its own circuit breaker (30-minute cache TTL) and `upstreamUnavailable` signaling for graceful degradation when WTO endpoints are temporarily unreachable. The panel is available on FULL and FINANCE variants. Trade policy data feeds into the data freshness tracker as `wto_trade`, with intelligence gap warnings when the WTO feed goes stale. + ### BTC ETF Flow Estimation Ten spot Bitcoin ETFs are tracked via Yahoo Finance's 5-day chart API (IBIT, FBTC, ARKB, BITB, GBTC, HODL, BRRR, EZBC, BTCO, BTCW). Since ETF flow data requires expensive terminal subscriptions, the system estimates flow direction from publicly available signals: @@ -911,7 +939,7 @@ A single codebase produces three specialized dashboards, each with distinct feed | **Domain** | worldmonitor.app | tech.worldmonitor.app | finance.worldmonitor.app | | **Focus** | Geopolitics, military, conflicts | AI/ML, startups, cybersecurity | Markets, trading, central banks | | **RSS Feeds** | ~25 categories (politics, MENA, Africa, think tanks) | ~20 categories (AI, VC blogs, startups, GitHub) | ~18 categories (forex, bonds, commodities, IPOs) | -| **Panels** | 44 (strategic posture, CII, cascade) | 31 (AI labs, unicorns, accelerators) | 30 (forex, bonds, derivatives, institutional) | +| **Panels** | 45 (strategic posture, CII, cascade, trade policy) | 31 (AI labs, unicorns, accelerators) | 31 (forex, bonds, derivatives, trade policy) | | **Unique Map Layers** | Military bases, nuclear facilities, hotspots | Tech HQs, cloud regions, startup hubs | Stock exchanges, central banks, Gulf investments | | **Desktop App** | World Monitor.app / .exe / .AppImage | Tech Monitor.app / .exe / .AppImage | Finance Monitor.app / .exe / .AppImage | @@ -962,11 +990,13 @@ World Monitor uses 60+ Vercel Edge Functions as a lightweight API layer, split i - **RSS Proxy** — domain-allowlisted proxy for 100+ feeds, preventing CORS issues and hiding origin servers. Feeds from domains that block Vercel IPs are automatically routed through the Railway relay. - **AI Pipeline** — Groq and OpenRouter edge functions with Redis deduplication, so identical headlines across concurrent users only trigger one LLM call. The classify-event endpoint pauses its queue on 500 errors to avoid wasting API quota. -- **Data Adapters** — GDELT, ACLED, OpenSky, USGS, NASA FIRMS, FRED, Yahoo Finance, CoinGecko, mempool.space, and others each have dedicated edge functions that normalize responses into consistent schemas +- **Data Adapters** — GDELT, ACLED, OpenSky, USGS, NASA FIRMS, FRED, Yahoo Finance, CoinGecko, mempool.space, BIS, WTO, and others each have dedicated edge functions that normalize responses into consistent schemas - **Market Intelligence** — macro signals, ETF flows, and stablecoin monitors compute derived analytics server-side (VWAP, SMA, peg deviation, flow estimates) and cache results in Redis - **Temporal Baseline** — Welford's algorithm state is persisted in Redis across requests, building statistical baselines without a traditional database - **Custom Scrapers** — sources without RSS feeds (FwdStart, GitHub Trending, tech events) are scraped and transformed into RSS-compatible formats - **Finance Geo Data** — stock exchanges (92), financial centers (19), central banks (13), and commodity hubs (10) are served as static typed datasets with market caps, GFCI rankings, trading hours, and commodity specializations +- **BIS Integration** — policy rates, real effective exchange rates, and credit-to-GDP ratios from the Bank for International Settlements, cached with 30-minute TTL +- **WTO Trade Policy** — trade restrictions, tariff trends, bilateral trade flows, and SPS/TBT barriers from the World Trade Organization All edge functions include circuit breaker logic and return cached stale data when upstream APIs are unavailable, ensuring the dashboard never shows blank panels. @@ -980,7 +1010,7 @@ All three variants run on three platforms that work together: ┌─────────────────────────────────────┐ │ Vercel (Edge) │ │ 60+ edge functions · static SPA │ -│ Proto gateway (17 typed services) │ +│ Proto gateway (19 typed services) │ │ CORS allowlist · Redis cache │ │ AI pipeline · market analytics │ │ CDN caching (s-maxage) · PWA host │ diff --git a/server/worldmonitor/trade/v1/_shared.ts b/server/worldmonitor/trade/v1/_shared.ts index 4fc3a72fd..0af528145 100644 --- a/server/worldmonitor/trade/v1/_shared.ts +++ b/server/worldmonitor/trade/v1/_shared.ts @@ -10,12 +10,12 @@ import { CHROME_UA } from '../../../_shared/constants'; /** WTO Timeseries API base URL. */ export const WTO_API_BASE = 'https://api.wto.org/timeseries/v1'; -/** Merchandise exports (total). */ +/** Merchandise exports (total) — annual. */ export const ITS_MTV_AX = 'ITS_MTV_AX'; -/** Merchandise imports (total). */ +/** Merchandise imports (total) — annual. */ export const ITS_MTV_AM = 'ITS_MTV_AM'; -/** Applied tariff — HS simple average. */ -export const HS_M_0010 = 'HS_M_0010'; +/** Simple average MFN applied tariff — all products. */ +export const TP_A_0010 = 'TP_A_0010'; /** * WTO member numeric codes → human-readable names. @@ -42,6 +42,9 @@ export const WTO_MEMBER_CODES: Record = { /** * Fetch JSON from the WTO Timeseries API. * Returns parsed JSON on success, or null if the API key is missing or the request fails. + * + * IMPORTANT: The WTO API does NOT support comma-separated indicator codes in the `i` param. + * Each indicator must be queried separately. */ export async function wtoFetch( path: string, @@ -66,6 +69,8 @@ export async function wtoFetch( signal: AbortSignal.timeout(15000), }); + // 204 = No Content (valid query, no matching data) + if (res.status === 204) return { Dataset: [] }; if (!res.ok) return null; return await res.json(); } catch { diff --git a/server/worldmonitor/trade/v1/get-tariff-trends.ts b/server/worldmonitor/trade/v1/get-tariff-trends.ts index 8c6ff0ffb..8365c1e73 100644 --- a/server/worldmonitor/trade/v1/get-tariff-trends.ts +++ b/server/worldmonitor/trade/v1/get-tariff-trends.ts @@ -1,6 +1,9 @@ /** * RPC: getTariffTrends -- WTO applied tariff trend data - * Fetches HS simple average applied tariff rates over time. + * Fetches MFN simple average applied tariff rates over time. + * + * NOTE: Tariff indicators (TP_A_*) do NOT have a partner dimension. + * The `partnerCountry` request field is accepted but not sent to the API. */ declare const process: { env: Record }; @@ -13,7 +16,7 @@ import type { } from '../../../../src/generated/server/worldmonitor/trade/v1/service_server'; import { getCachedJson, setCachedJson } from '../../../_shared/redis'; -import { wtoFetch, WTO_MEMBER_CODES, HS_M_0010 } from './_shared'; +import { wtoFetch, WTO_MEMBER_CODES, TP_A_0010 } from './_shared'; const REDIS_CACHE_TTL = 21600; // 6h @@ -37,38 +40,34 @@ function toDataPoint(row: any, reporter: string, partner: string): TariffDataPoi reportingCountry: WTO_MEMBER_CODES[reporter] ?? String(row.ReportingEconomy ?? row.reportingEconomy ?? reporter), partnerCountry: - WTO_MEMBER_CODES[partner] ?? String(row.PartnerEconomy ?? row.partnerEconomy ?? partner), - productSector: String(row.ProductOrSector ?? row.productOrSector ?? 'Total'), + WTO_MEMBER_CODES[partner] ?? String(row.PartnerEconomy ?? row.partnerEconomy ?? partner || 'World'), + productSector: String(row.ProductOrSector ?? row.productOrSector ?? 'All products'), year, tariffRate: Math.round(tariffRate * 100) / 100, boundRate: parseFloat(row.BoundRate ?? row.boundRate ?? '0') || 0, - indicatorCode: String(row.IndicatorCode ?? row.indicatorCode ?? HS_M_0010), + indicatorCode: String(row.IndicatorCode ?? row.indicatorCode ?? TP_A_0010), }; } async function fetchTariffTrends( reporter: string, partner: string, - productSector: string, + _productSector: string, years: number, ): Promise<{ datapoints: TariffDataPoint[]; ok: boolean }> { const currentYear = new Date().getFullYear(); const startYear = currentYear - years; + // Tariff indicators do NOT support the partner (p) parameter. const params: Record = { - i: HS_M_0010, + i: TP_A_0010, r: reporter, - p: partner || '000', ps: `${startYear}-${currentYear}`, fmt: 'json', mode: 'full', max: '500', }; - if (productSector) { - params.pc = productSector; - } - const data = await wtoFetch('/data', params); if (!data) return { datapoints: [], ok: false }; @@ -92,7 +91,7 @@ export async function getTariffTrends( const productSector = isValidCode(req.productSector) ? req.productSector : ''; const years = Math.max(1, Math.min(req.years > 0 ? req.years : 10, 30)); - const cacheKey = `trade:tariffs:v1:${reporter}:${partner}:${productSector || 'all'}:${years}`; + const cacheKey = `trade:tariffs:v1:${reporter}:${productSector || 'all'}:${years}`; const cached = (await getCachedJson(cacheKey)) as GetTariffTrendsResponse | null; if (cached?.datapoints?.length) return cached; diff --git a/server/worldmonitor/trade/v1/get-trade-barriers.ts b/server/worldmonitor/trade/v1/get-trade-barriers.ts index ea40b227d..a1df4325d 100644 --- a/server/worldmonitor/trade/v1/get-trade-barriers.ts +++ b/server/worldmonitor/trade/v1/get-trade-barriers.ts @@ -1,6 +1,11 @@ /** - * RPC: getTradeBarriers -- WTO SPS/TBT barrier notifications - * Fetches sanitary/phytosanitary and technical barrier notifications. + * RPC: getTradeBarriers -- WTO tariff barrier analysis + * + * Shows agricultural vs non-agricultural tariff gap and maximum duty rates + * as indicators of sector-specific trade barriers. + * + * NOTE: The WTO ePing API (SPS/TBT notifications) is a separate subscription product. + * This handler uses Timeseries API tariff data to surface sector-level trade barriers. */ declare const process: { env: Record }; @@ -17,8 +22,8 @@ import { wtoFetch, WTO_MEMBER_CODES } from './_shared'; const REDIS_CACHE_TTL = 21600; // 6h -/** Valid measure types for barrier queries. */ -const VALID_MEASURE_TYPES = ['SPS', 'TBT', 'ALL']; +/** Major economies to query. */ +const MAJOR_REPORTERS = ['840', '156', '276', '392', '826', '356', '076', '643', '410', '036', '124', '484', '250', '380', '528']; /** * Validate a country code string — alphanumeric, max 10 chars. @@ -27,53 +32,121 @@ function isValidCountry(c: string): boolean { return /^[a-zA-Z0-9]{1,10}$/.test(c); } -/** - * Transform a raw WTO data row into a TradeBarrier. - */ -function toBarrier(row: any): TradeBarrier | null { - if (!row) return null; - return { - id: String(row.id ?? row.Id ?? row.DocumentSymbol ?? ''), - notifyingCountry: - WTO_MEMBER_CODES[String(row.ReportingEconomyCode ?? row.reportingEconomyCode ?? '')] ?? - String(row.ReportingEconomy ?? row.reportingEconomy ?? row.Member ?? ''), - title: String(row.Title ?? row.title ?? row.Value ?? row.value ?? ''), - measureType: String(row.IndicatorCategory ?? row.indicatorCategory ?? row.Indicator ?? ''), - productDescription: String(row.ProductOrSector ?? row.productOrSector ?? ''), - objective: String(row.Objective ?? row.objective ?? ''), - status: String(row.ValueFlag ?? row.valueFlag ?? row.Status ?? 'notified'), - dateDistributed: String(row.Year ?? row.year ?? row.DateDistributed ?? ''), - sourceUrl: 'https://www.wto.org', - }; +interface TariffRow { + country: string; + countryCode: string; + indicator: string; + year: number; + value: number; +} + +function parseRows(data: any): TariffRow[] { + const dataset: any[] = Array.isArray(data) ? data : data?.Dataset ?? data?.dataset ?? []; + const rows: TariffRow[] = []; + + for (const row of dataset) { + const year = parseInt(row.Year ?? row.year ?? '0', 10); + const value = parseFloat(row.Value ?? row.value ?? ''); + if (isNaN(year) || isNaN(value)) continue; + + const countryCode = String(row.ReportingEconomyCode ?? ''); + rows.push({ + country: WTO_MEMBER_CODES[countryCode] ?? String(row.ReportingEconomy ?? ''), + countryCode, + indicator: String(row.IndicatorCode ?? ''), + year, + value, + }); + } + + return rows; } async function fetchBarriers( - countries: string[], - measureType: string, + _countries: string[], limit: number, ): Promise<{ barriers: TradeBarrier[]; ok: boolean }> { - // Determine indicator code based on measure type - const indicator = measureType === 'SPS' ? 'SPS' : measureType === 'TBT' ? 'TBT' : 'SPS,TBT'; + const currentYear = new Date().getFullYear(); + const reporters = MAJOR_REPORTERS.join(','); + + // Fetch agricultural and non-agricultural tariffs in parallel + const [agriData, nonAgriData] = await Promise.all([ + wtoFetch('/data', { + i: 'TP_A_0160', + r: reporters, + ps: `${currentYear - 3}-${currentYear}`, + fmt: 'json', + mode: 'full', + max: '500', + }), + wtoFetch('/data', { + i: 'TP_A_0430', + r: reporters, + ps: `${currentYear - 3}-${currentYear}`, + fmt: 'json', + mode: 'full', + max: '500', + }), + ]); + + if (!agriData && !nonAgriData) return { barriers: [], ok: false }; + + const agriRows = agriData ? parseRows(agriData) : []; + const nonAgriRows = nonAgriData ? parseRows(nonAgriData) : []; + + // Get most recent year per country for each indicator + const latestAgri = new Map(); + for (const row of agriRows) { + const existing = latestAgri.get(row.countryCode); + if (!existing || row.year > existing.year) { + latestAgri.set(row.countryCode, row); + } + } - const params: Record = { - i: indicator, - r: countries.length > 0 ? countries.join(',') : '000', - ps: 'all', - max: String(limit), - fmt: 'json', - mode: 'full', - }; + const latestNonAgri = new Map(); + for (const row of nonAgriRows) { + const existing = latestNonAgri.get(row.countryCode); + if (!existing || row.year > existing.year) { + latestNonAgri.set(row.countryCode, row); + } + } - const data = await wtoFetch('/data', params); - if (!data) return { barriers: [], ok: false }; + // Build barriers: show countries where agricultural tariffs significantly exceed non-agricultural + const barriers: TradeBarrier[] = []; + const allCodes = new Set([...latestAgri.keys(), ...latestNonAgri.keys()]); + + for (const code of allCodes) { + const agri = latestAgri.get(code); + const nonAgri = latestNonAgri.get(code); + if (!agri && !nonAgri) continue; + + const agriRate = agri?.value ?? 0; + const nonAgriRate = nonAgri?.value ?? 0; + const gap = agriRate - nonAgriRate; + const country = agri?.country ?? nonAgri?.country ?? code; + const year = String(agri?.year ?? nonAgri?.year ?? ''); + + barriers.push({ + id: `${code}-tariff-gap-${year}`, + notifyingCountry: country, + title: `Agricultural tariff: ${agriRate.toFixed(1)}% vs Non-agricultural: ${nonAgriRate.toFixed(1)}% (gap: ${gap > 0 ? '+' : ''}${gap.toFixed(1)}pp)`, + measureType: gap > 10 ? 'High agricultural protection' : gap > 5 ? 'Moderate agricultural protection' : 'Low tariff gap', + productDescription: 'Agricultural vs Non-agricultural products', + objective: gap > 0 ? 'Agricultural sector protection' : 'Uniform tariff structure', + status: gap > 10 ? 'high' : gap > 5 ? 'moderate' : 'low', + dateDistributed: year, + sourceUrl: 'https://stats.wto.org', + }); + } - const dataset: any[] = Array.isArray(data) ? data : data?.Dataset ?? data?.dataset ?? []; - const barriers = dataset - .map(toBarrier) - .filter((b): b is TradeBarrier => b !== null) - .slice(0, limit); + // Sort by gap (highest agricultural protection first) + barriers.sort((a, b) => { + const gapA = parseFloat(a.title.match(/gap: ([+-]?\d+\.?\d*)/)?.[1] ?? '0'); + const gapB = parseFloat(b.title.match(/gap: ([+-]?\d+\.?\d*)/)?.[1] ?? '0'); + return gapB - gapA; + }); - return { barriers, ok: true }; + return { barriers: barriers.slice(0, limit), ok: true }; } export async function getTradeBarriers( @@ -81,19 +154,14 @@ export async function getTradeBarriers( req: GetTradeBarriersRequest, ): Promise { try { - // Input validation const countries = (req.countries ?? []).filter(isValidCountry); - const measureType = - VALID_MEASURE_TYPES.includes((req.measureType ?? '').toUpperCase()) - ? (req.measureType ?? '').toUpperCase() - : 'ALL'; const limit = Math.max(1, Math.min(req.limit > 0 ? req.limit : 50, 100)); - const cacheKey = `trade:barriers:v1:${countries.sort().join(',') || 'all'}:${measureType}:${limit}`; + const cacheKey = `trade:barriers:v1:tariff-gap:${limit}`; const cached = (await getCachedJson(cacheKey)) as GetTradeBarriersResponse | null; if (cached?.barriers?.length) return cached; - const { barriers, ok } = await fetchBarriers(countries, measureType, limit); + const { barriers, ok } = await fetchBarriers(countries, limit); if (!ok) { return { diff --git a/server/worldmonitor/trade/v1/get-trade-flows.ts b/server/worldmonitor/trade/v1/get-trade-flows.ts index 6792082dd..3daf934d7 100644 --- a/server/worldmonitor/trade/v1/get-trade-flows.ts +++ b/server/worldmonitor/trade/v1/get-trade-flows.ts @@ -1,6 +1,9 @@ /** * RPC: getTradeFlows -- WTO merchandise trade flow data * Fetches bilateral export/import values and computes YoY changes. + * + * NOTE: The WTO API does NOT support comma-separated indicator codes. + * Exports and imports must be fetched in separate requests. */ declare const process: { env: Record }; @@ -33,15 +36,14 @@ interface RawFlowRow { /** * Parse raw WTO rows into a flat list of { year, indicator, value }. */ -function parseRows(data: any): RawFlowRow[] { +function parseRows(data: any, indicator: string): RawFlowRow[] { const dataset: any[] = Array.isArray(data) ? data : data?.Dataset ?? data?.dataset ?? []; const rows: RawFlowRow[] = []; for (const row of dataset) { const year = parseInt(row.Year ?? row.year ?? row.Period ?? '', 10); const value = parseFloat(row.Value ?? row.value ?? ''); - const indicator = String(row.IndicatorCode ?? row.indicatorCode ?? ''); - if (!isNaN(year) && !isNaN(value) && indicator) { + if (!isNaN(year) && !isNaN(value)) { rows.push({ year, indicator, value }); } } @@ -114,20 +116,29 @@ async function fetchTradeFlows( const currentYear = new Date().getFullYear(); const startYear = currentYear - years; - const params: Record = { - i: `${ITS_MTV_AX},${ITS_MTV_AM}`, + const baseParams: Record = { r: reporter, p: partner || '000', ps: `${startYear}-${currentYear}`, + pc: 'TO', fmt: 'json', mode: 'full', max: '500', }; - const data = await wtoFetch('/data', params); - if (!data) return { flows: [], ok: false }; + // Fetch exports and imports in parallel (separate requests — WTO API doesn't support comma-separated indicators) + const [exportsData, importsData] = await Promise.all([ + wtoFetch('/data', { ...baseParams, i: ITS_MTV_AX }), + wtoFetch('/data', { ...baseParams, i: ITS_MTV_AM }), + ]); + + if (!exportsData && !importsData) return { flows: [], ok: false }; + + const rows: RawFlowRow[] = [ + ...(exportsData ? parseRows(exportsData, ITS_MTV_AX) : []), + ...(importsData ? parseRows(importsData, ITS_MTV_AM) : []), + ]; - const rows = parseRows(data); const flows = buildFlowRecords(rows, reporter, partner || '000'); return { flows, ok: true }; diff --git a/server/worldmonitor/trade/v1/get-trade-restrictions.ts b/server/worldmonitor/trade/v1/get-trade-restrictions.ts index ec5ae871e..7090e9d95 100644 --- a/server/worldmonitor/trade/v1/get-trade-restrictions.ts +++ b/server/worldmonitor/trade/v1/get-trade-restrictions.ts @@ -1,6 +1,11 @@ /** - * RPC: getTradeRestrictions -- WTO trade restriction/QR notifications - * Fetches quantitative restriction and related trade measure data. + * RPC: getTradeRestrictions -- WTO tariff-based trade restriction overview + * + * Shows countries with highest applied tariff rates as a proxy for trade restrictiveness. + * Uses MFN simple average tariffs across all products, agricultural, and non-agricultural sectors. + * + * NOTE: The WTO Quantitative Restrictions (QR) API is a separate subscription product. + * This handler uses the Timeseries API tariff data as an available proxy for trade barriers. */ declare const process: { env: Record }; @@ -18,55 +23,77 @@ import { wtoFetch, WTO_MEMBER_CODES } from './_shared'; const REDIS_CACHE_KEY = 'trade:restrictions:v1'; const REDIS_CACHE_TTL = 21600; // 6h -/** - * Validate a country code string — alphanumeric, max 10 chars. - */ -function isValidCountry(c: string): boolean { - return /^[a-zA-Z0-9]{1,10}$/.test(c); -} +/** Major economies to query for tariff data. */ +const MAJOR_REPORTERS = ['840', '156', '276', '392', '826', '356', '076', '643', '410', '036', '124', '484', '250', '380', '528']; /** - * Transform a raw WTO data row into a TradeRestriction. + * Transform a raw WTO tariff row into a TradeRestriction (tariff-as-barrier view). */ function toRestriction(row: any): TradeRestriction | null { if (!row) return null; + const value = parseFloat(row.Value ?? row.value ?? ''); + if (isNaN(value)) return null; + + const reporterCode = String(row.ReportingEconomyCode ?? row.reportingEconomyCode ?? ''); + const year = String(row.Year ?? row.year ?? row.Period ?? ''); + const indicator = String(row.Indicator ?? row.indicator ?? row.IndicatorCode ?? ''); + return { - id: String(row.id ?? row.Id ?? ''), - reportingCountry: - WTO_MEMBER_CODES[String(row.ReportingEconomyCode ?? row.reportingEconomyCode ?? '')] ?? - String(row.ReportingEconomy ?? row.reportingEconomy ?? ''), - affectedCountry: - WTO_MEMBER_CODES[String(row.PartnerEconomyCode ?? row.partnerEconomyCode ?? '')] ?? - String(row.PartnerEconomy ?? row.partnerEconomy ?? ''), - productSector: String(row.ProductOrSector ?? row.productOrSector ?? ''), - measureType: String(row.IndicatorCategory ?? row.indicatorCategory ?? row.Indicator ?? ''), - description: String(row.Value ?? row.value ?? ''), - status: String(row.ValueFlag ?? row.valueFlag ?? 'active'), - notifiedAt: String(row.Year ?? row.year ?? row.Period ?? ''), - sourceUrl: 'https://www.wto.org', + id: `${reporterCode}-${year}-${row.IndicatorCode ?? ''}`, + reportingCountry: WTO_MEMBER_CODES[reporterCode] ?? String(row.ReportingEconomy ?? row.reportingEconomy ?? ''), + affectedCountry: 'All trading partners', + productSector: indicator.includes('agricultural') + ? (indicator.includes('non-') ? 'Non-agricultural products' : 'Agricultural products') + : 'All products', + measureType: 'MFN Applied Tariff', + description: `Average tariff rate: ${value.toFixed(1)}%`, + status: value > 10 ? 'high' : value > 5 ? 'moderate' : 'low', + notifiedAt: year, + sourceUrl: 'https://stats.wto.org', }; } async function fetchRestrictions( - countries: string[], + _countries: string[], limit: number, ): Promise<{ restrictions: TradeRestriction[]; ok: boolean }> { + const currentYear = new Date().getFullYear(); + + // Fetch all-products tariff for major economies (most recent years) const params: Record = { - i: 'QR', // Quantitative restrictions indicator group - r: countries.length > 0 ? countries.join(',') : '000', - ps: 'all', - max: String(limit), + i: 'TP_A_0010', + r: MAJOR_REPORTERS.join(','), + ps: `${currentYear - 3}-${currentYear}`, fmt: 'json', mode: 'full', + max: String(limit * 3), }; const data = await wtoFetch('/data', params); if (!data) return { restrictions: [], ok: false }; const dataset: any[] = Array.isArray(data) ? data : data?.Dataset ?? data?.dataset ?? []; - const restrictions = dataset + + // Keep only the most recent year per country + const latestByCountry = new Map(); + for (const row of dataset) { + const code = String(row.ReportingEconomyCode ?? ''); + const year = parseInt(row.Year ?? row.year ?? '0', 10); + const existing = latestByCountry.get(code); + if (!existing || year > parseInt(existing.Year ?? existing.year ?? '0', 10)) { + latestByCountry.set(code, row); + } + } + + const restrictions = Array.from(latestByCountry.values()) .map(toRestriction) .filter((r): r is TradeRestriction => r !== null) + .sort((a, b) => { + // Sort by tariff rate descending (extract from description) + const rateA = parseFloat(a.description.match(/[\d.]+/)?.[0] ?? '0'); + const rateB = parseFloat(b.description.match(/[\d.]+/)?.[0] ?? '0'); + return rateB - rateA; + }) .slice(0, limit); return { restrictions, ok: true }; @@ -77,18 +104,15 @@ export async function getTradeRestrictions( req: GetTradeRestrictionsRequest, ): Promise { try { - // Input validation - const countries = (req.countries ?? []).filter(isValidCountry); const limit = Math.max(1, Math.min(req.limit > 0 ? req.limit : 50, 100)); - const cacheKey = `${REDIS_CACHE_KEY}:${countries.sort().join(',') || 'all'}:${limit}`; + const cacheKey = `${REDIS_CACHE_KEY}:tariff-overview:${limit}`; const cached = (await getCachedJson(cacheKey)) as GetTradeRestrictionsResponse | null; if (cached?.restrictions?.length) return cached; - const { restrictions, ok } = await fetchRestrictions(countries, limit); + const { restrictions, ok } = await fetchRestrictions([], limit); if (!ok) { - // Upstream unavailable — return stale cache or empty return { restrictions: cached?.restrictions ?? [], fetchedAt: new Date().toISOString(), diff --git a/src/config/panels.ts b/src/config/panels.ts index bf1c07147..0df8fc1ef 100644 --- a/src/config/panels.ts +++ b/src/config/panels.ts @@ -45,7 +45,7 @@ const FULL_PANELS: Record = { '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 }, - giving: { name: 'Global Giving', enabled: true, priority: 2 }, + giving: { name: 'Global Giving', enabled: false, priority: 2 }, displacement: { name: 'UNHCR Displacement', enabled: true, priority: 2 }, climate: { name: 'Climate Anomalies', enabled: true, priority: 2 }, 'population-exposure': { name: 'Population Exposure', enabled: true, priority: 2 }, From ab078228a95f5d314f8b9c59e535ec24f610894d Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Wed, 25 Feb 2026 11:08:37 +0000 Subject: [PATCH 2/2] fix: add parens to disambiguate ?? and || precedence --- server/worldmonitor/trade/v1/get-tariff-trends.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/worldmonitor/trade/v1/get-tariff-trends.ts b/server/worldmonitor/trade/v1/get-tariff-trends.ts index 8365c1e73..680d0e407 100644 --- a/server/worldmonitor/trade/v1/get-tariff-trends.ts +++ b/server/worldmonitor/trade/v1/get-tariff-trends.ts @@ -40,7 +40,7 @@ function toDataPoint(row: any, reporter: string, partner: string): TariffDataPoi reportingCountry: WTO_MEMBER_CODES[reporter] ?? String(row.ReportingEconomy ?? row.reportingEconomy ?? reporter), partnerCountry: - WTO_MEMBER_CODES[partner] ?? String(row.PartnerEconomy ?? row.partnerEconomy ?? partner || 'World'), + WTO_MEMBER_CODES[partner] ?? String(row.PartnerEconomy ?? row.partnerEconomy ?? (partner || 'World')), productSector: String(row.ProductOrSector ?? row.productOrSector ?? 'All products'), year, tariffRate: Math.round(tariffRate * 100) / 100,