mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat: effective tariff rate source (#1790)
* feat: effective tariff rate source
* fix(trade): extract parse helpers, fix tests, add health monitoring
- Extract htmlToPlainText/toIsoDate/parseBudgetLabEffectiveTariffHtml
to scripts/_trade-parse-utils.mjs so tests can import directly
- Fix toIsoDate to use month-name lookup instead of fragile
new Date(\`\${text} UTC\`) which is not spec-guaranteed
- Replace new Function() test reconstruction with direct ESM import
- Add test fixtures for parser patterns 2 and 3 (previously untested)
- Add tariffTrendsUs to health.js STANDALONE_KEYS + SEED_META
(key trade:tariffs:v1:840:all:10, maxStaleMin 900 = 2.5x the 6h TTL)
* fix(test): update sourceVersion assertion for budgetlab addition
---------
Co-authored-by: Elie Habib <elie.habib@gmail.com>
This commit is contained in:
@@ -73,6 +73,7 @@ const STANDALONE_KEYS = {
|
||||
chokepointTransits: 'supply_chain:chokepoint_transits:v1',
|
||||
transitSummaries: 'supply_chain:transit-summaries:v1',
|
||||
thermalEscalation: 'thermal:escalation:v1',
|
||||
tariffTrendsUs: 'trade:tariffs:v1:840:all:10',
|
||||
};
|
||||
|
||||
const SEED_META = {
|
||||
@@ -135,6 +136,7 @@ const SEED_META = {
|
||||
sanctionsPressure: { key: 'seed-meta:sanctions:pressure', maxStaleMin: 720 },
|
||||
radiationWatch: { key: 'seed-meta:radiation:observations', maxStaleMin: 30 },
|
||||
thermalEscalation: { key: 'seed-meta:thermal:escalation', maxStaleMin: 240 },
|
||||
tariffTrendsUs: { key: 'seed-meta:trade:tariffs:v1:840:all:10', maxStaleMin: 900 },
|
||||
};
|
||||
|
||||
// Standalone keys that are populated on-demand by RPC handlers (not seeds).
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -334,6 +334,8 @@ components:
|
||||
upstreamUnavailable:
|
||||
type: boolean
|
||||
description: True if upstream fetch failed and results may be stale/empty.
|
||||
effectiveTariffRate:
|
||||
$ref: '#/components/schemas/EffectiveTariffRate'
|
||||
description: Response containing tariff trend datapoints.
|
||||
TariffDataPoint:
|
||||
type: object
|
||||
@@ -363,6 +365,26 @@ components:
|
||||
type: string
|
||||
description: WTO indicator code used for this datapoint.
|
||||
description: Single tariff data point for a reporter-partner-product combination.
|
||||
EffectiveTariffRate:
|
||||
type: object
|
||||
properties:
|
||||
sourceName:
|
||||
type: string
|
||||
description: Source name for the effective-rate estimate.
|
||||
sourceUrl:
|
||||
type: string
|
||||
description: Canonical source URL for the estimate/methodology.
|
||||
observationPeriod:
|
||||
type: string
|
||||
description: Human-readable observation period (for example "December 2025").
|
||||
updatedAt:
|
||||
type: string
|
||||
description: ISO 8601 date when the source page was last updated, if known.
|
||||
tariffRate:
|
||||
type: number
|
||||
format: double
|
||||
description: Effective tariff rate (percentage).
|
||||
description: Current effective tariff estimate for countries with coverage beyond WTO MFN baselines.
|
||||
GetTradeFlowsRequest:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -25,4 +25,6 @@ message GetTariffTrendsResponse {
|
||||
string fetched_at = 2;
|
||||
// True if upstream fetch failed and results may be stale/empty.
|
||||
bool upstream_unavailable = 3;
|
||||
// Optional effective tariff snapshot for countries with additional coverage (currently US only).
|
||||
EffectiveTariffRate effective_tariff_rate = 4;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,20 @@ message TariffDataPoint {
|
||||
string indicator_code = 7;
|
||||
}
|
||||
|
||||
// Current effective tariff estimate for countries with coverage beyond WTO MFN baselines.
|
||||
message EffectiveTariffRate {
|
||||
// Source name for the effective-rate estimate.
|
||||
string source_name = 1;
|
||||
// Canonical source URL for the estimate/methodology.
|
||||
string source_url = 2;
|
||||
// Human-readable observation period (for example "December 2025").
|
||||
string observation_period = 3;
|
||||
// ISO 8601 date when the source page was last updated, if known.
|
||||
string updated_at = 4;
|
||||
// Effective tariff rate (percentage).
|
||||
double tariff_rate = 5;
|
||||
}
|
||||
|
||||
// Bilateral trade flow record for a reporting-partner pair.
|
||||
message TradeFlowRecord {
|
||||
// WTO member code of reporting country.
|
||||
|
||||
79
scripts/_trade-parse-utils.mjs
Normal file
79
scripts/_trade-parse-utils.mjs
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Pure parse helpers for trade-data seed scripts.
|
||||
* Extracted so test files can import directly without new Function() hacks.
|
||||
*/
|
||||
|
||||
export const BUDGET_LAB_TARIFFS_URL = 'https://budgetlab.yale.edu/research/tracking-economic-effects-tariffs';
|
||||
|
||||
const MONTH_MAP = {
|
||||
january: '01', february: '02', march: '03', april: '04',
|
||||
may: '05', june: '06', july: '07', august: '08',
|
||||
september: '09', october: '10', november: '11', december: '12',
|
||||
};
|
||||
|
||||
export function htmlToPlainText(html) {
|
||||
return String(html ?? '')
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, '\'')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a human-readable date string like "March 2, 2026" to ISO "2026-03-02".
|
||||
* Falls back to '' on failure.
|
||||
*/
|
||||
export function toIsoDate(value) {
|
||||
const text = String(value ?? '').trim();
|
||||
if (!text) return '';
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(text)) return text.slice(0, 10);
|
||||
const m = text.match(/^([A-Za-z]+)\s+(\d{1,2}),?\s+(\d{4})/);
|
||||
if (m) {
|
||||
const mm = MONTH_MAP[m[1].toLowerCase()];
|
||||
if (mm) return `${m[3]}-${mm}-${m[2].padStart(2, '0')}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the Yale Budget Lab tariff-tracking page and extract effective tariff rate.
|
||||
*
|
||||
* Tries three patterns in priority order:
|
||||
* 1. "effective tariff rate reaching X% in [month year]"
|
||||
* 2. "average effective [U.S.] tariff rate ... to X% ... in/by [month year]"
|
||||
* 3. Same as 2 but no period capture
|
||||
*
|
||||
* Returns null when no recognisable rate is found.
|
||||
*/
|
||||
export function parseBudgetLabEffectiveTariffHtml(html) {
|
||||
const text = htmlToPlainText(html);
|
||||
if (!text) return null;
|
||||
|
||||
const updatedAt = toIsoDate(text.match(/\bUpdated:\s*([A-Za-z]+\s+\d{1,2},\s+\d{4})/i)?.[1] ?? '');
|
||||
const patterns = [
|
||||
/effective tariff rate reaching\s+(\d+(?:\.\d+)?)%\s+in\s+([A-Za-z]+\s+\d{4})/i,
|
||||
/average effective (?:u\.s\.\s*)?tariff rate[^.]{0,180}?\bto\s+(\d+(?:\.\d+)?)%[^.]{0,180}?\b(?:in|by)\s+([A-Za-z]+\s+\d{4})/i,
|
||||
/average effective (?:u\.s\.\s*)?tariff rate[^.]{0,180}?\bto\s+(\d+(?:\.\d+)?)%/i,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern);
|
||||
if (!match) continue;
|
||||
const tariffRate = parseFloat(match[1]);
|
||||
if (!Number.isFinite(tariffRate)) continue;
|
||||
return {
|
||||
sourceName: 'Yale Budget Lab',
|
||||
sourceUrl: BUDGET_LAB_TARIFFS_URL,
|
||||
observationPeriod: match[2] ?? '',
|
||||
updatedAt,
|
||||
tariffRate: Math.round(tariffRate * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { loadEnvFile, CHROME_UA, runSeed, writeExtraKeyWithMeta, sleep, verifySeedKey } from './_seed-utils.mjs';
|
||||
import { BUDGET_LAB_TARIFFS_URL, htmlToPlainText, toIsoDate, parseBudgetLabEffectiveTariffHtml } from './_trade-parse-utils.mjs';
|
||||
|
||||
loadEnvFile(import.meta.url);
|
||||
|
||||
@@ -234,6 +235,30 @@ async function wtoFetch(path, params) {
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function fetchBudgetLabEffectiveTariffRate() {
|
||||
try {
|
||||
const resp = await fetch(BUDGET_LAB_TARIFFS_URL, {
|
||||
headers: { Accept: 'text/html', 'User-Agent': CHROME_UA },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn(` Budget Lab tariffs: HTTP ${resp.status}`);
|
||||
return null;
|
||||
}
|
||||
const html = await resp.text();
|
||||
const parsed = parseBudgetLabEffectiveTariffHtml(html);
|
||||
if (!parsed) {
|
||||
console.warn(' Budget Lab tariffs: effective tariff rate not found in page content');
|
||||
return null;
|
||||
}
|
||||
console.log(` Budget Lab effective tariff: ${parsed.tariffRate.toFixed(1)}%${parsed.observationPeriod ? ` (${parsed.observationPeriod})` : ''}`);
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
console.warn(` Budget Lab tariffs: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Trade Flows (WTO) — pre-seed major reporters vs World + key bilateral pairs ───
|
||||
|
||||
const BILATERAL_PAIRS = [
|
||||
@@ -407,7 +432,7 @@ async function fetchTradeRestrictions() {
|
||||
id: `${cc}-${year}-${row.IndicatorCode ?? ''}`,
|
||||
reportingCountry: WTO_MEMBER_CODES[cc] ?? String(row.ReportingEconomy ?? ''),
|
||||
affectedCountry: 'All trading partners', productSector: 'All products',
|
||||
measureType: 'MFN Applied Tariff', description: `Average tariff rate: ${value.toFixed(1)}%`,
|
||||
measureType: 'WTO MFN Baseline', description: `WTO MFN baseline: ${value.toFixed(1)}%`,
|
||||
status: value > 10 ? 'high' : value > 5 ? 'moderate' : 'low',
|
||||
notifiedAt: year, sourceUrl: 'https://stats.wto.org',
|
||||
};
|
||||
@@ -426,6 +451,7 @@ async function fetchTradeRestrictions() {
|
||||
async function fetchTariffTrends() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const trends = {};
|
||||
const usEffectiveTariffRate = await fetchBudgetLabEffectiveTariffRate();
|
||||
|
||||
for (const reporter of MAJOR_REPORTERS) {
|
||||
const years = 10;
|
||||
@@ -449,7 +475,12 @@ async function fetchTariffTrends() {
|
||||
|
||||
if (datapoints.length > 0) {
|
||||
const cacheKey = `trade:tariffs:v1:${reporter}:all:${years}`;
|
||||
trends[cacheKey] = { datapoints, fetchedAt: new Date().toISOString(), upstreamUnavailable: false };
|
||||
trends[cacheKey] = {
|
||||
datapoints,
|
||||
...(reporter === '840' && usEffectiveTariffRate ? { effectiveTariffRate: usEffectiveTariffRate } : {}),
|
||||
fetchedAt: new Date().toISOString(),
|
||||
upstreamUnavailable: false,
|
||||
};
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
@@ -566,7 +597,7 @@ function validate(data) {
|
||||
runSeed('supply_chain', 'shipping', KEYS.shipping, fetchAll, {
|
||||
validateFn: validate,
|
||||
ttlSeconds: SHIPPING_TTL,
|
||||
sourceVersion: 'fred-wto-sse-bdi',
|
||||
sourceVersion: 'fred-wto-sse-bdi-budgetlab',
|
||||
}).catch((err) => {
|
||||
const _cause = err.cause ? ` (cause: ${err.cause.message || err.cause.code || err.cause})` : ''; console.error('FATAL:', (err.message || err) + _cause);
|
||||
process.exit(1);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* RPC: getTariffTrends -- reads seeded WTO tariff trend data from Railway seed cache.
|
||||
* All external WTO API calls happen in seed-supply-chain-trade.mjs on Railway.
|
||||
* RPC: getTariffTrends -- reads seeded WTO MFN tariff trends from Railway seed cache.
|
||||
* The seed payload may also include an optional US effective tariff snapshot.
|
||||
*/
|
||||
import type {
|
||||
ServerContext,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* RPC: getTradeRestrictions -- reads seeded WTO tariff restriction data from Railway seed cache.
|
||||
* RPC: getTradeRestrictions -- reads seeded WTO MFN baseline overview data from Railway seed cache.
|
||||
* All external WTO API calls happen in seed-supply-chain-trade.mjs on Railway.
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -5,6 +5,8 @@ import type {
|
||||
GetTradeFlowsResponse,
|
||||
GetTradeBarriersResponse,
|
||||
GetCustomsRevenueResponse,
|
||||
TariffDataPoint,
|
||||
EffectiveTariffRate,
|
||||
} from '@/services/trade';
|
||||
import { t } from '@/services/i18n';
|
||||
import { escapeHtml } from '@/utils/sanitize';
|
||||
@@ -81,7 +83,7 @@ export class TradePolicyPanel extends Panel {
|
||||
const tabsHtml = `
|
||||
<div class="panel-tabs">
|
||||
${wtoAvailable ? `<button class="panel-tab ${this.activeTab === 'restrictions' ? 'active' : ''}" data-tab="restrictions">
|
||||
${t('components.tradePolicy.restrictions')}
|
||||
${t('components.tradePolicy.overview')}
|
||||
</button>` : ''}
|
||||
${hasTariffs ? `<button class="panel-tab ${this.activeTab === 'tariffs' ? 'active' : ''}" data-tab="tariffs">
|
||||
${t('components.tradePolicy.tariffs')}
|
||||
@@ -125,14 +127,17 @@ export class TradePolicyPanel extends Panel {
|
||||
case 'revenue': contentHtml = this.renderRevenue(); break;
|
||||
}
|
||||
|
||||
const source = this.activeTab === 'revenue' ? t('components.tradePolicy.sourceTreasury') : t('components.tradePolicy.sourceWto');
|
||||
const source = this.activeTab === 'revenue' ? t('components.tradePolicy.sourceTreasury')
|
||||
: (this.activeTab === 'tariffs' || this.activeTab === 'restrictions') && this.tariffsData?.effectiveTariffRate?.sourceName
|
||||
? `${t('components.tradePolicy.sourceWto')} / ${this.tariffsData.effectiveTariffRate.sourceName}`
|
||||
: t('components.tradePolicy.sourceWto');
|
||||
|
||||
this.setContent(`
|
||||
${tabsHtml}
|
||||
${unavailableBanner}
|
||||
<div class="economic-content">${contentHtml}</div>
|
||||
<div class="economic-footer">
|
||||
<span class="economic-source">${source}</span>
|
||||
<span class="economic-source">${escapeHtml(source)}</span>
|
||||
</div>
|
||||
`);
|
||||
|
||||
@@ -140,10 +145,11 @@ export class TradePolicyPanel extends Panel {
|
||||
|
||||
private renderRestrictions(): string {
|
||||
if (!this.restrictionsData || !this.restrictionsData.restrictions?.length) {
|
||||
return `<div class="economic-empty">${t('components.tradePolicy.noRestrictions')}</div>`;
|
||||
return `<div class="economic-empty">${t('components.tradePolicy.noOverviewData')}</div>`;
|
||||
}
|
||||
|
||||
return `<div class="trade-restrictions-list">
|
||||
return `${this.renderRestrictionsContext()}
|
||||
<div class="trade-restrictions-list">
|
||||
${this.restrictionsData.restrictions.map(r => {
|
||||
const statusClass = r.status === 'high' ? 'status-active' : r.status === 'moderate' ? 'status-notified' : 'status-terminated';
|
||||
const statusLabel = r.status === 'high' ? t('components.tradePolicy.highTariff') : r.status === 'moderate' ? t('components.tradePolicy.moderateTariff') : t('components.tradePolicy.lowTariff');
|
||||
@@ -157,6 +163,7 @@ export class TradePolicyPanel extends Panel {
|
||||
<div class="trade-restriction-body">
|
||||
<div class="trade-sector">${escapeHtml(r.productSector)}</div>
|
||||
${r.description ? `<div class="trade-description">${escapeHtml(r.description)}</div>` : ''}
|
||||
${this.renderRestrictionEffectiveContext(r.reportingCountry)}
|
||||
${r.affectedCountry ? `<div class="trade-affected">Affects: ${escapeHtml(r.affectedCountry)}</div>` : ''}
|
||||
</div>
|
||||
<div class="trade-restriction-footer">
|
||||
@@ -168,12 +175,34 @@ export class TradePolicyPanel extends Panel {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderRestrictionsContext(): string {
|
||||
const gapSummary = this.getEffectiveTariffGapSummary();
|
||||
if (!gapSummary) {
|
||||
return `<div class="trade-policy-note">${t('components.tradePolicy.overviewNoteNoEffective')}</div>`;
|
||||
}
|
||||
|
||||
const gapSign = gapSummary.gap > 0 ? '+' : '';
|
||||
const sourceLink = this.renderSourceUrl(gapSummary.effectiveRate.sourceUrl);
|
||||
return `<div class="trade-policy-note">
|
||||
${t('components.tradePolicy.usBaselineLabel')}: <strong>${gapSummary.baseline.tariffRate.toFixed(1)}%</strong>.
|
||||
${t('components.tradePolicy.effectiveTariffRateLabel')}: <strong>${gapSummary.effectiveRate.tariffRate.toFixed(1)}%</strong>.
|
||||
${t('components.tradePolicy.gapLabel')}: <strong>${gapSign}${gapSummary.gap.toFixed(1)}pp</strong>.
|
||||
${t('components.tradePolicy.overviewNoteTail')}
|
||||
${sourceLink}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderTariffs(): string {
|
||||
if (!this.tariffsData || !this.tariffsData.datapoints?.length) {
|
||||
return `<div class="economic-empty">${t('components.tradePolicy.noTariffData')}</div>`;
|
||||
}
|
||||
|
||||
const rows = [...this.tariffsData.datapoints].sort((a, b) => b.year - a.year).map(d =>
|
||||
const sortedDatapoints = [...this.tariffsData.datapoints].sort((a, b) => b.year - a.year);
|
||||
const latestBaseline = sortedDatapoints[0] ?? null;
|
||||
const effectiveRate = this.tariffsData.effectiveTariffRate ?? null;
|
||||
const summaryHtml = this.renderTariffSummary(latestBaseline, effectiveRate);
|
||||
|
||||
const rows = sortedDatapoints.map(d =>
|
||||
`<tr>
|
||||
<td>${d.year}</td>
|
||||
<td>${d.tariffRate.toFixed(1)}%</td>
|
||||
@@ -181,12 +210,13 @@ export class TradePolicyPanel extends Panel {
|
||||
</tr>`
|
||||
).join('');
|
||||
|
||||
return `<div class="trade-tariffs-table">
|
||||
return `${summaryHtml}
|
||||
<div class="trade-tariffs-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Year</th>
|
||||
<th>${t('components.tradePolicy.appliedRate')}</th>
|
||||
<th>${t('components.tradePolicy.mfnAppliedRate')}</th>
|
||||
<th>Sector</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -195,6 +225,85 @@ export class TradePolicyPanel extends Panel {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderTariffSummary(latestBaseline: TariffDataPoint | null, effectiveRate: EffectiveTariffRate | null): string {
|
||||
if (!latestBaseline) return '';
|
||||
|
||||
const baselineMeta = t('components.tradePolicy.wtoBaselineMeta', { year: String(latestBaseline.year) });
|
||||
const baselineCard = `
|
||||
<div class="trade-tariff-card">
|
||||
<div class="trade-tariff-label">${t('components.tradePolicy.baselineMfnTariff')}</div>
|
||||
<div class="trade-tariff-value">${latestBaseline.tariffRate.toFixed(1)}%</div>
|
||||
<div class="trade-tariff-meta">${escapeHtml(baselineMeta)}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (!effectiveRate) {
|
||||
return `<div class="trade-tariff-summary">
|
||||
${baselineCard}
|
||||
<div class="trade-tariff-card trade-tariff-card-muted">
|
||||
<div class="trade-tariff-label">${t('components.tradePolicy.effectiveTariffRateLabel')}</div>
|
||||
<div class="trade-tariff-value">—</div>
|
||||
<div class="trade-tariff-meta">${t('components.tradePolicy.noEffectiveCoverageForCountry')}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const gap = effectiveRate.tariffRate - latestBaseline.tariffRate;
|
||||
const gapSign = gap > 0 ? '+' : '';
|
||||
const gapClass = gap >= 0 ? 'trade-tariff-gap-positive' : 'trade-tariff-gap-negative';
|
||||
const effectiveMetaParts = [
|
||||
effectiveRate.sourceName,
|
||||
effectiveRate.observationPeriod,
|
||||
effectiveRate.updatedAt ? `Updated ${effectiveRate.updatedAt}` : '',
|
||||
].filter(Boolean);
|
||||
const sourceLink = this.renderSourceUrl(effectiveRate.sourceUrl);
|
||||
|
||||
return `<div class="trade-tariff-summary">
|
||||
${baselineCard}
|
||||
<div class="trade-tariff-card">
|
||||
<div class="trade-tariff-label">${t('components.tradePolicy.effectiveTariffRateLabel')}</div>
|
||||
<div class="trade-tariff-value">${effectiveRate.tariffRate.toFixed(1)}%</div>
|
||||
<div class="trade-tariff-meta">
|
||||
${escapeHtml(effectiveMetaParts.join(' | '))}
|
||||
${sourceLink ? `<span class="trade-tariff-source">${sourceLink}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="trade-tariff-card">
|
||||
<div class="trade-tariff-label">${t('components.tradePolicy.gapLabel')}</div>
|
||||
<div class="trade-tariff-value ${gapClass}">${gapSign}${gap.toFixed(1)}pp</div>
|
||||
<div class="trade-tariff-meta">${t('components.tradePolicy.effectiveMinusBaseline')}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private getLatestBaselineTariffPoint(): TariffDataPoint | null {
|
||||
if (!this.tariffsData?.datapoints?.length) return null;
|
||||
return [...this.tariffsData.datapoints].sort((a, b) => b.year - a.year)[0] ?? null;
|
||||
}
|
||||
|
||||
private getEffectiveTariffGapSummary(): { baseline: TariffDataPoint; effectiveRate: EffectiveTariffRate; gap: number } | null {
|
||||
const baseline = this.getLatestBaselineTariffPoint();
|
||||
const effectiveRate = this.tariffsData?.effectiveTariffRate ?? null;
|
||||
if (!baseline || !effectiveRate) return null;
|
||||
return {
|
||||
baseline,
|
||||
effectiveRate,
|
||||
gap: effectiveRate.tariffRate - baseline.tariffRate,
|
||||
};
|
||||
}
|
||||
|
||||
private renderRestrictionEffectiveContext(reportingCountry: string): string {
|
||||
if (reportingCountry !== 'United States') return '';
|
||||
const gapSummary = this.getEffectiveTariffGapSummary();
|
||||
if (!gapSummary) return '';
|
||||
const gapSign = gapSummary.gap > 0 ? '+' : '';
|
||||
return `<div class="trade-policy-inline-note">
|
||||
${t('components.tradePolicy.effectiveTariffRateLabel')}: ${gapSummary.effectiveRate.tariffRate.toFixed(1)}%
|
||||
<span class="trade-policy-inline-sep">|</span>
|
||||
${t('components.tradePolicy.gapVsMfnLabel')}: ${gapSign}${gapSummary.gap.toFixed(1)}pp
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderFlows(): string {
|
||||
if (!this.flowsData || !this.flowsData.flows?.length) {
|
||||
return `<div class="economic-empty">${t('components.tradePolicy.noFlowData')}</div>`;
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface GetTariffTrendsResponse {
|
||||
datapoints: TariffDataPoint[];
|
||||
fetchedAt: string;
|
||||
upstreamUnavailable: boolean;
|
||||
effectiveTariffRate?: EffectiveTariffRate;
|
||||
}
|
||||
|
||||
export interface TariffDataPoint {
|
||||
@@ -48,6 +49,14 @@ export interface TariffDataPoint {
|
||||
indicatorCode: string;
|
||||
}
|
||||
|
||||
export interface EffectiveTariffRate {
|
||||
sourceName: string;
|
||||
sourceUrl: string;
|
||||
observationPeriod: string;
|
||||
updatedAt: string;
|
||||
tariffRate: number;
|
||||
}
|
||||
|
||||
export interface GetTradeFlowsRequest {
|
||||
reportingCountry: string;
|
||||
partnerCountry: string;
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface GetTariffTrendsResponse {
|
||||
datapoints: TariffDataPoint[];
|
||||
fetchedAt: string;
|
||||
upstreamUnavailable: boolean;
|
||||
effectiveTariffRate?: EffectiveTariffRate;
|
||||
}
|
||||
|
||||
export interface TariffDataPoint {
|
||||
@@ -48,6 +49,14 @@ export interface TariffDataPoint {
|
||||
indicatorCode: string;
|
||||
}
|
||||
|
||||
export interface EffectiveTariffRate {
|
||||
sourceName: string;
|
||||
sourceUrl: string;
|
||||
observationPeriod: string;
|
||||
updatedAt: string;
|
||||
tariffRate: number;
|
||||
}
|
||||
|
||||
export interface GetTradeFlowsRequest {
|
||||
reportingCountry: string;
|
||||
partnerCountry: string;
|
||||
|
||||
@@ -893,16 +893,29 @@
|
||||
},
|
||||
"tradePolicy": {
|
||||
"restrictions": "Restrictions",
|
||||
"overview": "Overview",
|
||||
"tariffs": "Tariffs",
|
||||
"flows": "Trade Flows",
|
||||
"barriers": "Barriers",
|
||||
"noRestrictions": "No active trade restrictions",
|
||||
"noOverviewData": "No tariff overview data available",
|
||||
"noTariffData": "No tariff data available",
|
||||
"noFlowData": "No trade flow data available",
|
||||
"noBarriers": "No trade barriers reported",
|
||||
"apiKeyMissing": "WTO API key required — add it in Settings",
|
||||
"upstreamUnavailable": "WTO data temporarily unavailable — showing cached data",
|
||||
"appliedRate": "Applied Rate",
|
||||
"mfnAppliedRate": "MFN Applied Rate",
|
||||
"baselineMfnTariff": "Baseline MFN tariff",
|
||||
"effectiveTariffRateLabel": "Effective tariff rate",
|
||||
"gapLabel": "Gap",
|
||||
"gapVsMfnLabel": "Gap vs MFN",
|
||||
"noEffectiveCoverageForCountry": "No effective-rate coverage for this country",
|
||||
"effectiveMinusBaseline": "Effective rate minus WTO MFN baseline",
|
||||
"wtoBaselineMeta": "WTO MFN applied rate | {{year}}",
|
||||
"overviewNoteNoEffective": "These figures are WTO MFN baseline rates, not the current tariff burden from unilateral tariff actions.",
|
||||
"usBaselineLabel": "US WTO MFN baseline",
|
||||
"overviewNoteTail": "These cards show WTO baseline commitments, not the live effective tariff burden.",
|
||||
"boundRate": "Bound Rate",
|
||||
"exports": "Exports",
|
||||
"imports": "Imports",
|
||||
@@ -920,7 +933,7 @@
|
||||
"colDate": "Date",
|
||||
"colMonthly": "Monthly",
|
||||
"colFytd": "FY YTD",
|
||||
"infoTooltip": "<strong>Trade Policy</strong> WTO trade monitoring:<ul><li><strong>Restrictions</strong>: Active trade measures by country and sector</li><li><strong>Tariffs</strong>: Applied tariff rate trends by year</li><li><strong>Trade Flows</strong>: Export/import volumes with year-over-year changes</li><li><strong>Barriers</strong>: Technical barriers to trade (TBT/SPS notifications)</li><li><strong>Revenue</strong>: Monthly US customs duties revenue (US Treasury MTS data)</li></ul>"
|
||||
"infoTooltip": "<strong>Trade Policy</strong> WTO baseline and tariff-impact monitoring:<ul><li><strong>Overview</strong>: WTO MFN baseline rates with US effective-rate context when available</li><li><strong>Tariffs</strong>: WTO MFN tariff trends vs the US effective tariff estimate</li><li><strong>Trade Flows</strong>: Export/import volumes with year-over-year changes</li><li><strong>Barriers</strong>: Technical barriers to trade (TBT/SPS notifications)</li><li><strong>Revenue</strong>: Monthly US customs duties revenue (US Treasury MTS data)</li></ul>"
|
||||
},
|
||||
"gdelt": {
|
||||
"empty": "No recent articles for this topic"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Trade policy intelligence service -- WTO data sources.
|
||||
* Trade restrictions, tariff trends, trade flows, and SPS/TBT barriers.
|
||||
* Trade policy intelligence service.
|
||||
* WTO MFN baselines, trade flows/barriers, and US customs/effective tariff context.
|
||||
*/
|
||||
|
||||
import { getRpcBaseUrl } from '@/services/rpc-client';
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type GetCustomsRevenueResponse,
|
||||
type TradeRestriction,
|
||||
type TariffDataPoint,
|
||||
type EffectiveTariffRate,
|
||||
type TradeFlowRecord,
|
||||
type TradeBarrier,
|
||||
type CustomsRevenueMonth,
|
||||
@@ -22,7 +23,7 @@ import { isFeatureAvailable } from '../runtime-config';
|
||||
import { getHydratedData } from '@/services/bootstrap';
|
||||
|
||||
// Re-export types for consumers
|
||||
export type { TradeRestriction, TariffDataPoint, TradeFlowRecord, TradeBarrier, CustomsRevenueMonth };
|
||||
export type { TradeRestriction, TariffDataPoint, EffectiveTariffRate, TradeFlowRecord, TradeBarrier, CustomsRevenueMonth };
|
||||
export type {
|
||||
GetTradeRestrictionsResponse,
|
||||
GetTariffTrendsResponse,
|
||||
|
||||
@@ -9380,6 +9380,87 @@ a.prediction-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.trade-policy-note {
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 10px;
|
||||
background: var(--overlay-subtle);
|
||||
border-left: 2px solid var(--accent);
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.trade-policy-note strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.trade-policy-inline-note {
|
||||
margin-top: 4px;
|
||||
font-size: 9px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.trade-policy-inline-sep {
|
||||
margin: 0 4px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.trade-tariff-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.trade-tariff-card {
|
||||
padding: 8px 10px;
|
||||
background: var(--overlay-subtle);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.trade-tariff-card-muted {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.trade-tariff-label {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.trade-tariff-value {
|
||||
margin-top: 4px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.trade-tariff-meta {
|
||||
margin-top: 4px;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.trade-tariff-source {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.trade-tariff-gap-positive {
|
||||
color: var(--orange, #ffab40);
|
||||
}
|
||||
|
||||
.trade-tariff-gap-negative {
|
||||
color: var(--green, #69f0ae);
|
||||
}
|
||||
|
||||
/* Trade Tariffs Table */
|
||||
.trade-tariffs-table {
|
||||
width: 100%;
|
||||
|
||||
@@ -328,7 +328,7 @@ describe('Seed script structure', () => {
|
||||
});
|
||||
|
||||
it('updated sourceVersion reflects new sources', () => {
|
||||
assert.ok(seedSrc.includes("'fred-wto-sse-bdi'"));
|
||||
assert.ok(seedSrc.includes("'fred-wto-sse-bdi-budgetlab'"));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
168
tests/trade-policy-tariffs.test.mjs
Normal file
168
tests/trade-policy-tariffs.test.mjs
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseBudgetLabEffectiveTariffHtml, toIsoDate, htmlToPlainText, BUDGET_LAB_TARIFFS_URL } from '../scripts/_trade-parse-utils.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(__dirname, '..');
|
||||
|
||||
const protoSrc = readFileSync(join(root, 'proto/worldmonitor/trade/v1/get_tariff_trends.proto'), 'utf-8');
|
||||
const tradeDataProtoSrc = readFileSync(join(root, 'proto/worldmonitor/trade/v1/trade_data.proto'), 'utf-8');
|
||||
const seedSrc = readFileSync(join(root, 'scripts/seed-supply-chain-trade.mjs'), 'utf-8');
|
||||
const panelSrc = readFileSync(join(root, 'src/components/TradePolicyPanel.ts'), 'utf-8');
|
||||
const serviceSrc = readFileSync(join(root, 'src/services/trade/index.ts'), 'utf-8');
|
||||
const clientGeneratedSrc = readFileSync(join(root, 'src/generated/client/worldmonitor/trade/v1/service_client.ts'), 'utf-8');
|
||||
const serverGeneratedSrc = readFileSync(join(root, 'src/generated/server/worldmonitor/trade/v1/service_server.ts'), 'utf-8');
|
||||
|
||||
describe('Trade tariff proto contract', () => {
|
||||
it('adds EffectiveTariffRate message to shared trade data', () => {
|
||||
assert.match(tradeDataProtoSrc, /message EffectiveTariffRate/);
|
||||
assert.match(tradeDataProtoSrc, /string source_name = 1;/);
|
||||
assert.match(tradeDataProtoSrc, /double tariff_rate = 5;/);
|
||||
});
|
||||
|
||||
it('adds optional effective_tariff_rate to GetTariffTrendsResponse', () => {
|
||||
assert.match(protoSrc, /EffectiveTariffRate effective_tariff_rate = 4;/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Generated tariff types', () => {
|
||||
it('client types expose an optional effectiveTariffRate snapshot', () => {
|
||||
assert.match(clientGeneratedSrc, /effectiveTariffRate\?: EffectiveTariffRate/);
|
||||
});
|
||||
|
||||
it('server types expose an optional effectiveTariffRate snapshot', () => {
|
||||
assert.match(serverGeneratedSrc, /effectiveTariffRate\?: EffectiveTariffRate/);
|
||||
});
|
||||
|
||||
it('trade service re-exports EffectiveTariffRate', () => {
|
||||
assert.match(serviceSrc, /export type \{[^}]*EffectiveTariffRate/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Budget Lab effective tariff seed integration', () => {
|
||||
it('imports parse helpers from shared utils module', () => {
|
||||
assert.match(seedSrc, /_trade-parse-utils\.mjs/);
|
||||
assert.match(seedSrc, /parseBudgetLabEffectiveTariffHtml/);
|
||||
});
|
||||
|
||||
it('attaches the effective tariff snapshot only to the US tariff payload', () => {
|
||||
assert.match(seedSrc, /reporter === '840' && usEffectiveTariffRate/);
|
||||
});
|
||||
|
||||
it('keeps restrictions snapshot labeled as WTO MFN baseline data', () => {
|
||||
assert.match(seedSrc, /measureType: 'WTO MFN Baseline'/);
|
||||
assert.match(seedSrc, /description: `WTO MFN baseline: \$\{value\.toFixed\(1\)\}%`/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseBudgetLabEffectiveTariffHtml — pattern 1 (rate reaching … in period)', () => {
|
||||
it('parses tariff rate, observation period, and updated date', () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div>Updated: March 2, 2026</div>
|
||||
<p>U.S. consumers face tariff changes, raising the effective tariff rate reaching 9.9% in December 2025.</p>
|
||||
</body></html>
|
||||
`;
|
||||
assert.deepEqual(parseBudgetLabEffectiveTariffHtml(html), {
|
||||
sourceName: 'Yale Budget Lab',
|
||||
sourceUrl: BUDGET_LAB_TARIFFS_URL,
|
||||
observationPeriod: 'December 2025',
|
||||
updatedAt: '2026-03-02',
|
||||
tariffRate: 9.9,
|
||||
});
|
||||
});
|
||||
|
||||
it('rounds to 2 decimal places', () => {
|
||||
const html = '<p>effective tariff rate reaching 12.345% in January 2026</p>';
|
||||
assert.equal(parseBudgetLabEffectiveTariffHtml(html)?.tariffRate, 12.35);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseBudgetLabEffectiveTariffHtml — pattern 2 (average effective … to X% … in period)', () => {
|
||||
it('parses rate and period via "average effective tariff rate … to X% … in" phrasing', () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div>Updated: January 15, 2026</div>
|
||||
<p>Our estimates show the average effective U.S. tariff rate has risen to 18.5% in February 2026 from pre-tariff levels.</p>
|
||||
</body></html>
|
||||
`;
|
||||
const result = parseBudgetLabEffectiveTariffHtml(html);
|
||||
assert.ok(result, 'expected a non-null result for pattern 2');
|
||||
assert.equal(result.tariffRate, 18.5);
|
||||
assert.equal(result.observationPeriod, 'February 2026');
|
||||
assert.equal(result.updatedAt, '2026-01-15');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseBudgetLabEffectiveTariffHtml — pattern 3 (rate without period)', () => {
|
||||
it('parses rate when observation period is absent, leaving observationPeriod empty', () => {
|
||||
const html = '<p>The average effective tariff rate has climbed to 22.1%.</p>';
|
||||
const result = parseBudgetLabEffectiveTariffHtml(html);
|
||||
assert.ok(result, 'expected a non-null result for pattern 3');
|
||||
assert.equal(result.tariffRate, 22.1);
|
||||
assert.equal(result.observationPeriod, '');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseBudgetLabEffectiveTariffHtml — edge cases', () => {
|
||||
it('returns null when page contains no recognizable rate', () => {
|
||||
assert.equal(parseBudgetLabEffectiveTariffHtml('<html><body><p>No tariff data here.</p></body></html>'), null);
|
||||
});
|
||||
|
||||
it('strips HTML tags before matching', () => {
|
||||
const html = '<p>effective tariff rate reaching <strong>7.5%</strong> in <em>March 2026</em></p>';
|
||||
const result = parseBudgetLabEffectiveTariffHtml(html);
|
||||
assert.ok(result);
|
||||
assert.equal(result.tariffRate, 7.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toIsoDate helper', () => {
|
||||
it('converts "March 2, 2026" to 2026-03-02', () => {
|
||||
assert.equal(toIsoDate('March 2, 2026'), '2026-03-02');
|
||||
});
|
||||
|
||||
it('passes through an already-ISO date unchanged', () => {
|
||||
assert.equal(toIsoDate('2026-01-15'), '2026-01-15');
|
||||
});
|
||||
|
||||
it('returns empty string for unparseable input', () => {
|
||||
assert.equal(toIsoDate('not a date'), '');
|
||||
assert.equal(toIsoDate(''), '');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Trade policy tariff panel', () => {
|
||||
it('renames the misleading Restrictions tab to Overview', () => {
|
||||
assert.match(panelSrc, /components\.tradePolicy\.overview/);
|
||||
assert.match(panelSrc, /components\.tradePolicy\.noOverviewData/);
|
||||
});
|
||||
|
||||
it('labels the WTO series as an MFN baseline', () => {
|
||||
assert.match(panelSrc, /components\.tradePolicy\.baselineMfnTariff/);
|
||||
assert.match(panelSrc, /components\.tradePolicy\.mfnAppliedRate/);
|
||||
});
|
||||
|
||||
it('shows effective tariff and gap cards when coverage exists', () => {
|
||||
assert.match(panelSrc, /components\.tradePolicy\.effectiveTariffRateLabel/);
|
||||
assert.match(panelSrc, /components\.tradePolicy\.gapLabel/);
|
||||
assert.match(panelSrc, /components\.tradePolicy\.effectiveMinusBaseline/);
|
||||
});
|
||||
|
||||
it('keeps a graceful MFN-only fallback for countries without effective-rate coverage', () => {
|
||||
assert.match(panelSrc, /components\.tradePolicy\.noEffectiveCoverageForCountry/);
|
||||
});
|
||||
|
||||
it('clarifies on the Restrictions tab that WTO figures are baselines, not live tariff burden', () => {
|
||||
assert.match(panelSrc, /components\.tradePolicy\.overviewNoteNoEffective/);
|
||||
assert.match(panelSrc, /components\.tradePolicy\.overviewNoteTail/);
|
||||
});
|
||||
|
||||
it('adds inline US effective-rate context on the overview card', () => {
|
||||
assert.match(panelSrc, /renderRestrictionEffectiveContext/);
|
||||
assert.match(panelSrc, /components\.tradePolicy\.gapVsMfnLabel/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user