From 5494577a4d74131fda80d1ed628252eff12cc7be Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Sun, 5 Apr 2026 12:52:20 +0400 Subject: [PATCH] feat(energy): EIA SPR levels and refinery utilization (#2710) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(seeds): EIA SPR levels and refinery utilization rates - Add fetchSprLevels() fetching WCSSTUS1 (Strategic Petroleum Reserve) from EIA /v2/petroleum/stoc/wstk/data/ - Add fetchRefineryUtilization() fetching WCRFPUS2 (refinery utilization %) from EIA /v2/petroleum/pnp/wiup/data/ - Export parseEiaSprRow and parseEiaRefineryRow helpers for testability - Both integrated into fetchAll() via Promise.allSettled; written with writeExtraKeyWithMeta at SPR_TTL/REFINERY_TTL (21 days, 3x weekly cadence) - Add SPR_KEY and REFINERY_UTIL_KEY exports to server/_shared/cache-keys.ts - Register both keys in api/health.js BOOTSTRAP_KEYS and SEED_META (maxStaleMin: 20160) - 25 passing unit tests in tests/economy-eia-spr-seed.test.mjs * fix(seeds): switch refinery series WCRFPUS2→WCRRIUS2 (EIA v2 exposes inputs not %) * fix(seeds): add isMain guard to seed-economy.mjs (fixes CI test import side-effect) * fix(seeds): rename refineryUtil→refineryInputs, export TTL constants, fix tautological tests - Rename REFINERY_UTIL_KEY → REFINERY_INPUTS_KEY and 'economic:refinery-util:v1' → 'economic:refinery-inputs:v1' in cache-keys.ts, health.js, and seed-economy.mjs. The seeded data is crude oil input volume (WCRRIUS2, MBBL/D), not a utilization rate (%). Keeping 'util' in the key would cause future consumers to mislabel the metric as a percentage. - Export SPR_TTL and REFINERY_INPUTS_TTL from seed-economy.mjs so tests can import them directly instead of copying local literals. - Replace the four tautological constant/TTL tests in economy-eia-spr-seed.test.mjs with tests that import the real exported values and assert consumer payload shape. The old tests compared hardcoded locals to themselves and would pass even after a key rename or TTL change. - Add a comment to the SPR payload shape test warning consumers not to divide barrels again (values are already in M bbl as returned by EIA WCSSTUS1). * fix(seeds): rename fetchRefineryUtilization→fetchRefineryInputs --- api/health.js | 4 + scripts/seed-economy.mjs | 181 ++++++++++++++++++++++++++-- server/_shared/cache-keys.ts | 2 + tests/economy-eia-spr-seed.test.mjs | 160 ++++++++++++++++++++++++ 4 files changed, 338 insertions(+), 9 deletions(-) create mode 100644 tests/economy-eia-spr-seed.test.mjs diff --git a/api/health.js b/api/health.js index c92039930..a90f1a245 100644 --- a/api/health.js +++ b/api/health.js @@ -72,6 +72,8 @@ const BOOTSTRAP_KEYS = { cotPositioning: 'market:cot:v1', crudeInventories: 'economic:crude-inventories:v1', natGasStorage: 'economic:nat-gas-storage:v1', + spr: 'economic:spr:v1', + refineryInputs: 'economic:refinery-inputs:v1', ecbFxRates: 'economic:ecb-fx-rates:v1', eurostatCountryData: 'economic:eurostat-country-data:v1', euGasStorage: 'economic:eu-gas-storage:v1', @@ -232,6 +234,8 @@ const SEED_META = { cotPositioning: { key: 'seed-meta:market:cot', maxStaleMin: 14400 }, // weekly CFTC release; 14400min = 10d = 1.4x interval (weekend + delay buffer) crudeInventories: { key: 'seed-meta:economic:crude-inventories', maxStaleMin: 20160 }, // weekly EIA data; 20160min = 14 days = 2x weekly cadence natGasStorage: { key: 'seed-meta:economic:nat-gas-storage', maxStaleMin: 20160 }, // weekly EIA data; 20160min = 14 days = 2x weekly cadence + spr: { key: 'seed-meta:economic:spr', maxStaleMin: 20160 }, // weekly EIA data; 20160min = 14 days = 2x weekly cadence + refineryInputs: { key: 'seed-meta:economic:refinery-inputs', maxStaleMin: 20160 }, // weekly EIA data; 20160min = 14 days = 2x weekly cadence ecbFxRates: { key: 'seed-meta:economic:ecb-fx-rates', maxStaleMin: 2880 }, // daily seed; 2880min = 48h = 2x interval eurostatCountryData: { key: 'seed-meta:economic:eurostat-country-data', maxStaleMin: 4320 }, // daily seed; 4320min = 3 days = 3x interval euGasStorage: { key: 'seed-meta:economic:eu-gas-storage', maxStaleMin: 2880 }, // daily seed (T+1); 2880min = 48h = 2x interval diff --git a/scripts/seed-economy.mjs b/scripts/seed-economy.mjs index 2b28e978c..83e5931e9 100755 --- a/scripts/seed-economy.mjs +++ b/scripts/seed-economy.mjs @@ -14,6 +14,8 @@ const KEYS = { macroSignals: 'economic:macro-signals:v1', crudeInventories: 'economic:crude-inventories:v1', natGasStorage: 'economic:nat-gas-storage:v1', + spr: 'economic:spr:v1', + refineryInputs: 'economic:refinery-inputs:v1', }; const FRED_KEY_PREFIX = 'economic:fred:v1'; @@ -27,6 +29,10 @@ const CRUDE_INVENTORIES_TTL = 1_814_400; // 21 days — EIA publishes weekly; 3x const CRUDE_MIN_WEEKS = 4; // require at least 4 weeks to guard against quota-hit empty responses const NAT_GAS_TTL = 1_814_400; // 21 days — EIA publishes weekly; 3x cadence per gold standard const NAT_GAS_MIN_WEEKS = 4; // require at least 4 weeks to guard against quota-hit empty responses +export const SPR_TTL = 1_814_400; // 21 days (3× weekly) +export const REFINERY_INPUTS_TTL = 1_814_400; // 21 days (3× weekly) +const SPR_MIN_WEEKS = 4; // require at least 4 weeks to guard against quota-hit empty responses +const REFINERY_MIN_WEEKS = 4; // require at least 4 weeks to guard against quota-hit empty responses const FRED_SERIES = ['WALCL', 'FEDFUNDS', 'T10Y2Y', 'UNRATE', 'CPIAUCSL', 'DGS10', 'VIXCLS', 'GDP', 'M2SL', 'DCOILWTICO', 'BAMLH0A0HYM2', 'ICSA', 'MORTGAGE30US', 'BAMLC0A0CM', 'SOFR', 'DGS1MO', 'DGS3MO', 'DGS6MO', 'DGS1', 'DGS2', 'DGS5', 'DGS30', 'T10Y3M', 'STLFSI4']; @@ -616,18 +622,155 @@ async function fetchNatGasStorage() { return { weeks, latestPeriod }; } +// ─── EIA Strategic Petroleum Reserve (WCSSTUS1) ─── + +/** + * @param {{ value: unknown, period: unknown } | null | undefined} row + * @returns {{ barrels: number, period: string } | null} + */ +export function parseEiaSprRow(row) { + if (!row) return null; + const barrels = row.value != null ? parseFloat(String(row.value)) : null; + if (barrels == null || !Number.isFinite(barrels)) return null; + const period = typeof row.period === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(row.period) ? row.period : ''; + return { barrels: +barrels.toFixed(3), period }; +} + +async function fetchSprLevels() { + const apiKey = process.env.EIA_API_KEY; + if (!apiKey) throw new Error('Missing EIA_API_KEY'); + + const params = new URLSearchParams({ + api_key: apiKey, + 'facets[series][]': 'WCSSTUS1', + frequency: 'weekly', + 'data[]': 'value', + 'sort[0][column]': 'period', + 'sort[0][direction]': 'desc', + length: '9', // fetch 9 so we can compute 4-week change + }); + const resp = await fetch(`https://api.eia.gov/v2/petroleum/stoc/wstk/data/?${params}`, { + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(10_000), + }); + if (!resp.ok) throw new Error(`EIA WCSSTUS1: HTTP ${resp.status}`); + const data = await resp.json(); + const rows = data.response?.data; + if (!rows || rows.length === 0) throw new Error('EIA WCSSTUS1: no data rows'); + + // rows are sorted newest-first + const weeks = []; + for (let i = 0; i < Math.min(rows.length, 9); i++) { + const parsed = parseEiaSprRow(rows[i]); + if (!parsed) continue; + weeks.push(parsed); + if (weeks.length === 8) break; // only return 8 weeks to client + } + + if (weeks.length < SPR_MIN_WEEKS) throw new Error(`EIA WCSSTUS1: only ${weeks.length} valid rows (need >= ${SPR_MIN_WEEKS})`); + + const latest = weeks[0]; + const prev = weeks[1] ?? null; + const prev4 = weeks[4] ?? null; + + const changeWoW = prev ? +(latest.barrels - prev.barrels).toFixed(3) : null; + const changeWoW4 = prev4 ? +(latest.barrels - prev4.barrels).toFixed(3) : null; + + const latestPeriod = latest.period; + console.log(` SPR levels: ${weeks.length} weeks, latest=${latestPeriod}, barrels=${latest.barrels}M`); + + return { + latestPeriod, + barrels: latest.barrels, + changeWoW, + changeWoW4, + weeks: weeks.map((w) => ({ period: w.period, barrels: w.barrels })), + seededAt: new Date().toISOString(), + }; +} + +// ─── EIA Refinery Crude Inputs (WCRRIUS2) ─── +// Note: EIA v2 API does not expose refinery utilization rate (%) as a direct weekly series. +// WCRRIUS2 = U.S. Refiner Net Input of Crude Oil (Thousand Barrels per Day, MBBL/D). +// This is the closest available weekly proxy for refinery activity. + +/** + * @param {{ value: unknown, period: unknown } | null | undefined} row + * @returns {{ inputsMbblpd: number, period: string } | null} + */ +export function parseEiaRefineryRow(row) { + if (!row) return null; + const inputsMbblpd = row.value != null ? parseFloat(String(row.value)) : null; + if (inputsMbblpd == null || !Number.isFinite(inputsMbblpd)) return null; + const period = typeof row.period === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(row.period) ? row.period : ''; + return { inputsMbblpd: +inputsMbblpd.toFixed(3), period }; +} + +async function fetchRefineryInputs() { + const apiKey = process.env.EIA_API_KEY; + if (!apiKey) throw new Error('Missing EIA_API_KEY'); + + const params = new URLSearchParams({ + api_key: apiKey, + 'facets[series][]': 'WCRRIUS2', + 'facets[duoarea][]': 'NUS', + frequency: 'weekly', + 'data[]': 'value', + 'sort[0][column]': 'period', + 'sort[0][direction]': 'desc', + length: '9', // fetch 9 so the oldest of 8 has a prior week for WoW change + }); + const resp = await fetch(`https://api.eia.gov/v2/petroleum/pnp/wiup/data/?${params}`, { + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(10_000), + }); + if (!resp.ok) throw new Error(`EIA WCRRIUS2: HTTP ${resp.status}`); + const data = await resp.json(); + const rows = data.response?.data; + if (!rows || rows.length === 0) throw new Error('EIA WCRRIUS2: no data rows'); + + // rows are sorted newest-first + const weeks = []; + for (let i = 0; i < Math.min(rows.length, 9); i++) { + const parsed = parseEiaRefineryRow(rows[i]); + if (!parsed) continue; + weeks.push(parsed); + if (weeks.length === 8) break; // only return 8 weeks to client + } + + if (weeks.length < REFINERY_MIN_WEEKS) throw new Error(`EIA WCRRIUS2: only ${weeks.length} valid rows (need >= ${REFINERY_MIN_WEEKS})`); + + const latest = weeks[0]; + const prev = weeks[1] ?? null; + + const changeWoW = prev ? +(latest.inputsMbblpd - prev.inputsMbblpd).toFixed(3) : null; + + const latestPeriod = latest.period; + console.log(` Refinery inputs: ${weeks.length} weeks, latest=${latestPeriod}, inputs=${latest.inputsMbblpd} MBBL/D`); + + return { + latestPeriod, + inputsMbblpd: latest.inputsMbblpd, + changeWoW, + weeks: weeks.map((w) => ({ period: w.period, inputsMbblpd: w.inputsMbblpd })), + seededAt: new Date().toISOString(), + }; +} + // ─── Main: seed all economic data ─── // NOTE: runSeed() calls process.exit(0) after writing the primary key. // All secondary keys MUST be written inside fetchAll() before returning. async function fetchAll() { - const [energyPrices, energyCapacity, fredResults, macroSignals, crudeInventories, natGasStorage] = await Promise.allSettled([ + const [energyPrices, energyCapacity, fredResults, macroSignals, crudeInventories, natGasStorage, sprLevels, refineryInputs] = await Promise.allSettled([ fetchEnergyPrices(), fetchEnergyCapacity(), fetchFredSeries(), fetchMacroSignals(_curlProxyAuth), fetchCrudeInventories(), fetchNatGasStorage(), + fetchSprLevels(), + fetchRefineryInputs(), ]); const ep = energyPrices.status === 'fulfilled' ? energyPrices.value : null; @@ -636,6 +779,8 @@ async function fetchAll() { const ms = macroSignals.status === 'fulfilled' ? macroSignals.value : null; const ci = crudeInventories.status === 'fulfilled' ? crudeInventories.value : null; const ng = natGasStorage.status === 'fulfilled' ? natGasStorage.value : null; + const spr = sprLevels.status === 'fulfilled' ? sprLevels.value : null; + const ru = refineryInputs.status === 'fulfilled' ? refineryInputs.value : null; if (energyPrices.status === 'rejected') console.warn(` EnergyPrices failed: ${energyPrices.reason?.message || energyPrices.reason}`); if (energyCapacity.status === 'rejected') console.warn(` EnergyCapacity failed: ${energyCapacity.reason?.message || energyCapacity.reason}`); @@ -643,6 +788,8 @@ async function fetchAll() { if (macroSignals.status === 'rejected') console.warn(` MacroSignals failed: ${macroSignals.reason?.message || macroSignals.reason}`); if (crudeInventories.status === 'rejected') console.warn(` CrudeInventories failed: ${crudeInventories.reason?.message || crudeInventories.reason}`); if (natGasStorage.status === 'rejected') console.warn(` NatGasStorage failed: ${natGasStorage.reason?.message || natGasStorage.reason}`); + if (sprLevels.status === 'rejected') console.warn(` SPRLevels failed: ${sprLevels.reason?.message || sprLevels.reason}`); + if (refineryInputs.status === 'rejected') console.warn(` RefineryInputs failed: ${refineryInputs.reason?.message || refineryInputs.reason}`); const frHasData = fr && Object.keys(fr).length > 0; if (!ep && !frHasData && !ms) throw new Error('All economic fetches failed'); @@ -672,6 +819,20 @@ async function fetchAll() { console.warn(` NatGasStorage: skipped write — ${ng.weeks?.length ?? 0} weeks or schema invalid`); } + const isValidSprWeek = (w) => typeof w.period === 'string' && typeof w.barrels === 'number' && Number.isFinite(w.barrels); + if (spr?.weeks?.length >= SPR_MIN_WEEKS && spr.weeks.every(isValidSprWeek)) { + await writeExtraKeyWithMeta(KEYS.spr, spr, SPR_TTL, spr.weeks.length); + } else if (spr) { + console.warn(` SPRLevels: skipped write — ${spr.weeks?.length ?? 0} weeks or schema invalid`); + } + + const isValidRuWeek = (w) => typeof w.period === 'string' && typeof w.inputsMbblpd === 'number' && Number.isFinite(w.inputsMbblpd); + if (ru?.weeks?.length >= REFINERY_MIN_WEEKS && ru.weeks.every(isValidRuWeek)) { + await writeExtraKeyWithMeta(KEYS.refineryInputs, ru, REFINERY_INPUTS_TTL, ru.weeks.length); + } else if (ru) { + console.warn(` RefineryInputs: skipped write — ${ru.weeks?.length ?? 0} weeks or schema invalid`); + } + // Compute stress index — GSCPI is seeded by ais-relay (NY Fed), not FRED; read from Redis if (frHasData) { const gscpi = await fetchGscpiFromRedis(); @@ -694,11 +855,13 @@ function validate(data) { return data?.prices?.length > 0; } -runSeed('economic', 'energy-prices', KEYS.energyPrices, fetchAll, { - validateFn: validate, - ttlSeconds: ENERGY_TTL, - sourceVersion: 'eia-fred-macro', -}).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); -}); +if (process.argv[1]?.endsWith('seed-economy.mjs')) { + runSeed('economic', 'energy-prices', KEYS.energyPrices, fetchAll, { + validateFn: validate, + ttlSeconds: ENERGY_TTL, + sourceVersion: 'eia-fred-macro', + }).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); + }); +} diff --git a/server/_shared/cache-keys.ts b/server/_shared/cache-keys.ts index 71d8995e5..4c134bcdc 100644 --- a/server/_shared/cache-keys.ts +++ b/server/_shared/cache-keys.ts @@ -52,6 +52,8 @@ export const ENERGY_MIX_KEY_PREFIX = 'energy:mix:v1:'; export const ENERGY_EXPOSURE_INDEX_KEY = 'energy:exposure:v1:index'; export const GAS_STORAGE_KEY_PREFIX = 'energy:gas-storage:v1:'; export const GAS_STORAGE_COUNTRIES_KEY = 'energy:gas-storage:v1:_countries'; +export const SPR_KEY = 'economic:spr:v1'; +export const REFINERY_INPUTS_KEY = 'economic:refinery-inputs:v1'; /** * Static cache keys for the bootstrap endpoint. diff --git a/tests/economy-eia-spr-seed.test.mjs b/tests/economy-eia-spr-seed.test.mjs new file mode 100644 index 000000000..e9f78a9f4 --- /dev/null +++ b/tests/economy-eia-spr-seed.test.mjs @@ -0,0 +1,160 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseEiaSprRow, parseEiaRefineryRow, SPR_TTL, REFINERY_INPUTS_TTL } from '../scripts/seed-economy.mjs'; + +// ─── Key constants (imported from cache-keys pattern) ─── +// These tests intentionally cross-check the seed's internal strings against +// the expected Redis key format so a key rename in either place fails loudly. + +describe('seed Redis key strings', () => { + it('SPR payload shape matches expected consumer contract', () => { + // Verify what consumers of economic:spr:v1 will read + const result = parseEiaSprRow({ value: '370.2', period: '2026-03-28' }); + assert.ok(result !== null); + assert.ok('barrels' in result, 'SPR payload must have barrels field'); + assert.ok('period' in result, 'SPR payload must have period field'); + assert.equal(typeof result.barrels, 'number', 'barrels must be a number (already in M bbl — do NOT divide again)'); + }); + + it('refinery key follows economic:refinery-inputs:v1 convention', () => { + // Verify the shape of a minimal seeded refinery payload (what consumers will read) + const result = parseEiaRefineryRow({ value: '15973', period: '2026-03-28' }); + assert.ok(result !== null); + assert.ok('inputsMbblpd' in result, 'Refinery payload must have inputsMbblpd field (not utilization %)'); + assert.ok('period' in result, 'Refinery payload must have period field'); + }); +}); + +// ─── TTL constants (imported from seed-economy) ─── + +describe('TTL constants', () => { + it('SPR_TTL is at least 21 days in seconds', () => { + assert.ok(SPR_TTL >= 21 * 24 * 3600, `SPR_TTL ${SPR_TTL} < 21 days`); + }); + + it('REFINERY_INPUTS_TTL is at least 21 days in seconds', () => { + assert.ok(REFINERY_INPUTS_TTL >= 21 * 24 * 3600, `REFINERY_INPUTS_TTL ${REFINERY_INPUTS_TTL} < 21 days`); + }); +}); + +// ─── parseEiaSprRow ─── + +describe('parseEiaSprRow', () => { + it('parses a numeric string value', () => { + const result = parseEiaSprRow({ value: '370.2', period: '2026-03-28' }); + assert.ok(result !== null); + assert.equal(result.barrels, 370.2); + assert.equal(result.period, '2026-03-28'); + }); + + it('parses a numeric value', () => { + const result = parseEiaSprRow({ value: 370.234, period: '2026-03-21' }); + assert.ok(result !== null); + assert.equal(result.barrels, 370.234); + }); + + it('returns null for null value', () => { + assert.equal(parseEiaSprRow({ value: null, period: '2026-03-28' }), null); + }); + + it('returns null for empty string value', () => { + assert.equal(parseEiaSprRow({ value: '', period: '2026-03-28' }), null); + }); + + it('returns null for NaN value', () => { + assert.equal(parseEiaSprRow({ value: 'N/A', period: '2026-03-28' }), null); + }); + + it('returns null for undefined row', () => { + assert.equal(parseEiaSprRow(undefined), null); + }); + + it('returns null for null row', () => { + assert.equal(parseEiaSprRow(null), null); + }); + + it('sets period to empty string for invalid date format', () => { + const result = parseEiaSprRow({ value: '370.2', period: '2026/03/28' }); + assert.ok(result !== null); + assert.equal(result.period, ''); + }); + + it('rounds barrels to 3 decimal places', () => { + const result = parseEiaSprRow({ value: '370.12345', period: '2026-03-28' }); + assert.ok(result !== null); + assert.equal(result.barrels, 370.123); + }); +}); + +// ─── computeSprWoW (inline logic mirroring fetchSprLevels) ─── + +describe('computeSprWoW', () => { + it('computes correct WoW delta', () => { + const latest = { barrels: 370.2 }; + const prev = { barrels: 371.6 }; + const changeWoW = +(latest.barrels - prev.barrels).toFixed(3); + assert.equal(changeWoW, -1.4); + }); + + it('returns null when prev is null', () => { + const prev = null; + const changeWoW = prev ? +(370.2 - prev.barrels).toFixed(3) : null; + assert.equal(changeWoW, null); + }); + + it('computes correct 4-week change', () => { + const latest = { barrels: 370.2 }; + const prev4 = { barrels: 375.4 }; + const changeWoW4 = +(latest.barrels - prev4.barrels).toFixed(3); + assert.equal(changeWoW4, -5.2); + }); +}); + +// ─── parseEiaRefineryRow ─── + +describe('parseEiaRefineryRow', () => { + it('parses a numeric string value', () => { + const result = parseEiaRefineryRow({ value: '15973', period: '2026-03-28' }); + assert.ok(result !== null); + assert.equal(result.inputsMbblpd, 15973); + assert.equal(result.period, '2026-03-28'); + }); + + it('parses a numeric value', () => { + const result = parseEiaRefineryRow({ value: 15973, period: '2026-03-21' }); + assert.ok(result !== null); + assert.equal(result.inputsMbblpd, 15973); + }); + + it('returns null for null value', () => { + assert.equal(parseEiaRefineryRow({ value: null, period: '2026-03-28' }), null); + }); + + it('returns null for empty string value', () => { + assert.equal(parseEiaRefineryRow({ value: '', period: '2026-03-28' }), null); + }); + + it('returns null for NaN string value', () => { + assert.equal(parseEiaRefineryRow({ value: 'N/A', period: '2026-03-28' }), null); + }); + + it('returns null for undefined row', () => { + assert.equal(parseEiaRefineryRow(undefined), null); + }); + + it('returns null for null row', () => { + assert.equal(parseEiaRefineryRow(null), null); + }); + + it('sets period to empty string for invalid date format', () => { + const result = parseEiaRefineryRow({ value: '15973', period: '20260328' }); + assert.ok(result !== null); + assert.equal(result.period, ''); + }); + + it('rounds inputsMbblpd to 3 decimal places', () => { + const result = parseEiaRefineryRow({ value: '15973.12345', period: '2026-03-28' }); + assert.ok(result !== null); + assert.equal(result.inputsMbblpd, 15973.123); + }); +});