mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
fix(trade): correct UN Comtrade reporter codes for India and Taiwan (#3081)
* fix(trade): correct UN Comtrade reporter codes for India and Taiwan
seed-trade-flows was fetching India (356) and Taiwan (158) using UN M49
codes. UN Comtrade registers India as 699 and Taiwan as 490 ("Other
Asia, nes"), so every fetch silently returned count:0 — 10 of 30
reporter×commodity pairs yielded zero records per run. Live probe
confirms 699→500 India rows, 490→159 Taiwan rows.
- Update reporter codes in seed-trade-flows.mjs and its consumer
list-comtrade-flows.ts.
- Update ISO2_TO_COMTRADE in _comtrade-reporters.ts and
seed-energy-spine.mjs so energy-shock and sector-dependency RPCs
resolve the correct Comtrade keys for IN/TW.
- Add IN/TW overrides to seed-comtrade-bilateral-hs4 and
seed-recovery-import-hhi (they iterate shared/un-to-iso2.json which
must remain pure M49 for other callers).
- Fix partner-dedupe bug in seed-trade-flows: the preview endpoint
returns partner-level rows; keying by (flowCode, year) without
summing kept only the last partner seen, so tradeValueUsd was a
random counterparty's value, not the World aggregate. Sum across
partners and label as World.
- Add a 70% coverage floor on reporter×commodity pairs so an entire
reporter silently flatlining now throws in Phase 1 (TTL extend, no
seed-meta refresh) rather than publishing a partial snapshot.
- Sync energy-shock test fixture.
* fix(trade): apply Comtrade IN/TW overrides to runtime consumers too
Follow-up to PR review: the seed-side fix was incomplete because two
request-time consumers still mapped iso2 → M49 (356/158) when hitting
Comtrade or reading the (now-rekeyed) seeded cache.
- server/worldmonitor/supply-chain/v1/_bilateral-hs4-lazy.ts: apply
the IN=699 / TW=490 override when deriving ISO2_TO_UN, so the lazy
bilateral-hs4 fetch path used by get-route-impact and
get-country-chokepoint-index stops silently returning count:0 for
India and Taiwan when the seeded cache is cold.
- src/utils/country-codes.ts: add iso2ToComtradeReporterCode helper
with the override baked in. Keep iso2ToUnCode as pure M49 (used
elsewhere for legitimate M49 semantics).
- src/app/country-intel.ts: switch the listComtradeFlows call on the
country brief page to the new helper so IN/TW resolve to the same
reporter codes the seeder now writes under.
This commit is contained in:
@@ -85,6 +85,8 @@ const COMTRADE_REPORTER_OVERRIDES = {
|
||||
FR: '251', // UN M49 standard is 250, but Comtrade registers France as reporter 251
|
||||
IT: '381', // UN M49 standard is 380, but Comtrade registers Italy as reporter 381
|
||||
US: '842', // UN M49 standard is 840, but Comtrade registers the US as reporter 842
|
||||
IN: '699', // UN M49 standard is 356, Comtrade registers India as reporter 699
|
||||
TW: '490', // M49 has no entry; Comtrade reports Taiwan as 490 "Other Asia, nes"
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,8 +30,8 @@ const ISO2_TO_COMTRADE = {
|
||||
CN: '156',
|
||||
RU: '643',
|
||||
IR: '364',
|
||||
IN: '356',
|
||||
TW: '158',
|
||||
IN: '699',
|
||||
TW: '490',
|
||||
};
|
||||
|
||||
// Chokepoints supported by the shock model for comtrade-mapped countries.
|
||||
|
||||
@@ -39,9 +39,16 @@ if (COMTRADE_KEYS.length === 0) {
|
||||
const COMTRADE_URL = 'https://comtradeapi.un.org/data/v1/get/C/A/HS';
|
||||
const PER_KEY_DELAY_MS = 600;
|
||||
|
||||
// UN M49 codes mostly match UN Comtrade reporterCodes, except for India (699,
|
||||
// not 356) and Taiwan (490 "Other Asia, nes", not 158). Using M49 codes for
|
||||
// these silently returns count:0 from the Comtrade API.
|
||||
const COMTRADE_REPORTER_OVERRIDES = { IN: '699', TW: '490' };
|
||||
const ISO2_TO_UN = Object.fromEntries(
|
||||
Object.entries(UN_TO_ISO2).map(([un, iso2]) => [iso2, un]),
|
||||
);
|
||||
for (const [iso2, code] of Object.entries(COMTRADE_REPORTER_OVERRIDES)) {
|
||||
ISO2_TO_UN[iso2] = code;
|
||||
}
|
||||
|
||||
const ALL_REPORTERS = Object.values(UN_TO_ISO2).filter(c => c.length === 2);
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ const KEY_PREFIX = 'comtrade:flows';
|
||||
const COMTRADE_BASE = 'https://comtradeapi.un.org/public/v1';
|
||||
const INTER_REQUEST_DELAY_MS = 3_000;
|
||||
const ANOMALY_THRESHOLD = 0.30; // 30% YoY change
|
||||
// Require at least this fraction of (reporter × commodity) pairs to return
|
||||
// non-empty flows. Guards against an entire reporter silently flatlining
|
||||
// (e.g., wrong reporterCode → HTTP 200 with count:0 for every commodity).
|
||||
const MIN_COVERAGE_RATIO = 0.70;
|
||||
|
||||
// Strategic reporters: US, China, Russia, Iran, India, Taiwan
|
||||
const REPORTERS = [
|
||||
@@ -19,8 +23,8 @@ const REPORTERS = [
|
||||
{ code: '156', name: 'China' },
|
||||
{ code: '643', name: 'Russia' },
|
||||
{ code: '364', name: 'Iran' },
|
||||
{ code: '356', name: 'India' },
|
||||
{ code: '158', name: 'Taiwan' },
|
||||
{ code: '699', name: 'India' },
|
||||
{ code: '490', name: 'Taiwan' },
|
||||
];
|
||||
|
||||
// Strategic HS commodity codes
|
||||
@@ -50,9 +54,10 @@ async function fetchFlows(reporter, commodity) {
|
||||
const records = data?.data ?? [];
|
||||
if (!Array.isArray(records)) return [];
|
||||
|
||||
// Group by (flowCode, year) to keep exports and imports separate.
|
||||
// Using just year as key caused the second flow direction (M after X) to
|
||||
// silently overwrite the first, producing wrong YoY values.
|
||||
// The preview endpoint returns partner-level rows (one per counterparty).
|
||||
// Aggregate to World totals per (flowCode, year) by summing, so YoY is
|
||||
// computed against full-year totals. Keying on (flowCode, year) without
|
||||
// summing would silently drop every partner except the last one seen.
|
||||
const byFlowYear = new Map(); // key: `${flowCode}:${year}`
|
||||
for (const r of records) {
|
||||
const year = Number(r.period ?? r.refYear ?? r.refMonth?.slice(0, 4) ?? 0);
|
||||
@@ -60,10 +65,14 @@ async function fetchFlows(reporter, commodity) {
|
||||
const flowCode = String(r.flowCode ?? r.rgDesc ?? 'X');
|
||||
const val = Number(r.primaryValue ?? r.cifvalue ?? r.fobvalue ?? 0);
|
||||
const wt = Number(r.netWgt ?? 0);
|
||||
const partnerCode = String(r.partnerCode ?? r.partner2Code ?? '000').padStart(3, '0');
|
||||
const partnerName = String(r.partnerDesc ?? r.partner2Desc ?? 'World');
|
||||
const mapKey = `${flowCode}:${year}`;
|
||||
byFlowYear.set(mapKey, { year, flowCode, val, wt, partnerCode, partnerName });
|
||||
const prev = byFlowYear.get(mapKey);
|
||||
if (prev) {
|
||||
prev.val += val;
|
||||
prev.wt += wt;
|
||||
} else {
|
||||
byFlowYear.set(mapKey, { year, flowCode, val, wt, partnerCode: '000', partnerName: 'World' });
|
||||
}
|
||||
}
|
||||
|
||||
// Derive the set of (flowCode, year) pairs sorted for YoY lookup.
|
||||
@@ -121,6 +130,14 @@ async function fetchAllFlows() {
|
||||
}
|
||||
}
|
||||
|
||||
const total = REPORTERS.length * COMMODITIES.length;
|
||||
const populated = Object.values(perKeyFlows).filter((v) => (v.flows?.length ?? 0) > 0).length;
|
||||
const coverage = populated / total;
|
||||
console.log(` Coverage: ${populated}/${total} (${(coverage * 100).toFixed(0)}%) reporter×commodity pairs populated`);
|
||||
if (coverage < MIN_COVERAGE_RATIO) {
|
||||
throw new Error(`coverage ${populated}/${total} below floor ${MIN_COVERAGE_RATIO}; refusing to publish partial snapshot`);
|
||||
}
|
||||
|
||||
return { flows: allFlows, perKeyFlows, fetchedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ export const ISO2_TO_COMTRADE: Record<string, string> = {
|
||||
ID: '360',
|
||||
IE: '372',
|
||||
IL: '376',
|
||||
IN: '356',
|
||||
IN: '699',
|
||||
IQ: '368',
|
||||
IR: '364',
|
||||
IS: '352',
|
||||
@@ -174,7 +174,7 @@ export const ISO2_TO_COMTRADE: Record<string, string> = {
|
||||
TR: '792',
|
||||
TT: '780',
|
||||
TV: '798',
|
||||
TW: '158',
|
||||
TW: '490',
|
||||
TZ: '834',
|
||||
UA: '804',
|
||||
UG: '800',
|
||||
|
||||
@@ -43,9 +43,16 @@ const HS4_LABELS: Record<string, string> = {
|
||||
'8704': 'Commercial Vehicles', '8708': 'Auto Parts',
|
||||
};
|
||||
|
||||
// UN M49 mostly matches UN Comtrade reporterCodes, except India (699, not 356)
|
||||
// and Taiwan (490 "Other Asia, nes", not 158). Using M49 codes silently yields
|
||||
// count:0 from the Comtrade API for these two countries.
|
||||
const COMTRADE_REPORTER_OVERRIDES: Record<string, string> = { IN: '699', TW: '490' };
|
||||
const ISO2_TO_UN: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(UN_TO_ISO2 as Record<string, string>).map(([un, iso]) => [iso, un]),
|
||||
);
|
||||
for (const [iso2, code] of Object.entries(COMTRADE_REPORTER_OVERRIDES)) {
|
||||
ISO2_TO_UN[iso2] = code;
|
||||
}
|
||||
|
||||
let fetchInFlight = false;
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { isCallerPremium } from '../../../_shared/premium-check';
|
||||
const KEY_PREFIX = 'comtrade:flows';
|
||||
|
||||
// Strategic reporters and commodities mirrored from the seed script.
|
||||
const REPORTERS = ['842', '156', '643', '364', '356', '158'];
|
||||
const REPORTERS = ['842', '156', '643', '364', '699', '490'];
|
||||
const CMD_CODES = ['2709', '2711', '7108', '8542', '9301'];
|
||||
|
||||
function isValidCode(c: string): boolean {
|
||||
|
||||
@@ -44,7 +44,7 @@ import type { StrategicPosturePanel } from '@/components/StrategicPosturePanel';
|
||||
import type { NewsItem } from '@/types';
|
||||
import { getNearbyInfrastructure } from '@/services/related-assets';
|
||||
import { toFlagEmoji } from '@/utils/country-flag';
|
||||
import { iso2ToIso3, iso2ToUnCode } from '@/utils/country-codes';
|
||||
import { iso2ToIso3, iso2ToComtradeReporterCode } from '@/utils/country-codes';
|
||||
import { buildDependencyGraph } from '@/services/infrastructure-cascade';
|
||||
import { getActiveFrameworkForPanel, subscribeFrameworkChange } from '@/services/analysis-framework-store';
|
||||
import { fetchMultiSectorExposure, fetchCountryProducts, fetchMultiSectorCostShock } from '@/services/supply-chain';
|
||||
@@ -610,7 +610,7 @@ export class CountryIntelManager implements AppModule {
|
||||
if (this.ctx.countryBriefPage?.getCode() === code) this.ctx.countryBriefPage.updateSanctionsPressure?.(null);
|
||||
});
|
||||
|
||||
const unCode = iso2ToUnCode(code);
|
||||
const unCode = iso2ToComtradeReporterCode(code);
|
||||
if (unCode) {
|
||||
tradeClient.listComtradeFlows({ reporterCode: unCode, cmdCode: '', anomaliesOnly: false }).then(resp => {
|
||||
if (this.ctx.countryBriefPage?.getCode() !== code) return;
|
||||
|
||||
@@ -71,3 +71,12 @@ export function iso2ToIso3(iso2: string): string | null {
|
||||
export function iso2ToUnCode(iso2: string): string | null {
|
||||
return ISO2_TO_UN[iso2.toUpperCase()] ?? null;
|
||||
}
|
||||
|
||||
// UN M49 codes mostly match UN Comtrade reporterCodes except India (699, not
|
||||
// 356) and Taiwan (490 "Other Asia, nes", not 158). Use this helper wherever
|
||||
// an iso2 feeds into a Comtrade reporterCode lookup or seeded comtrade:* key.
|
||||
const ISO2_TO_COMTRADE_OVERRIDES: Record<string, string> = { IN: '699', TW: '490' };
|
||||
export function iso2ToComtradeReporterCode(iso2: string): string | null {
|
||||
const upper = iso2.toUpperCase();
|
||||
return ISO2_TO_COMTRADE_OVERRIDES[upper] ?? ISO2_TO_UN[upper] ?? null;
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('energy shock scenario computation', () => {
|
||||
|
||||
it('returns hasData=false when country has no Comtrade data (no numeric code mapping)', () => {
|
||||
const ISO2_TO_COMTRADE: Record<string, string> = {
|
||||
US: '842', CN: '156', RU: '643', IR: '364', IN: '356', TW: '158',
|
||||
US: '842', CN: '156', RU: '643', IR: '364', IN: '699', TW: '490',
|
||||
};
|
||||
const unsupportedCountries = ['DE', 'FR', 'JP', 'KR', 'BR', 'SA'];
|
||||
for (const code of unsupportedCountries) {
|
||||
|
||||
Reference in New Issue
Block a user