#!/usr/bin/env node /** * Audit every symbol declared in `marketCountryStockIndexes` against Yahoo * Finance, through the same helper and URL shape the seeder uses (#6240). * * Reports, per country, how many finite daily closes the 1-month chart carries * and whether `buildCountryStockIndexSnapshot` — the builder the seeder publishes * through — accepts the chart. A chart it rejects is a country the RPC can only * ever answer `available: false`. * * Exit status: * 1 the contract disagrees with Yahoo in either direction: * - a serviceable entry produced no snapshot (flag it, or fix the symbol), or * - an `unavailable` entry now produces one (lift the flag). * 2 no disagreement, but at least one probe failed (rate limit, timeout, * proxy error). A failed probe says nothing about the symbol, so it never * counts toward either verdict above; re-run before changing a flag. * 0 every probe succeeded and matched the contract. * * Network-bound, so it is not part of `npm run test:data`; run it by hand when * adding a country or revisiting a flag: * * node scripts/audit-country-stock-index-symbols.mjs * * Like the seeder, it loads `.env.local` so `fetchYahooJson` can fall back to the * PROXY_URL curl path when Yahoo rate-limits the direct request. */ import { loadDeclaredCountryStockIndexes } from './_country-stock-index-registry.mjs'; import { MOEX_SOURCE } from './_country-stock-index-moex.mjs'; import { buildCountryStockIndexSnapshot } from './_country-stock-index.mjs'; import { loadEnvFile } from './_seed-utils.mjs'; import { fetchYahooJson } from './_yahoo-fetch.mjs'; import { isMainModule } from './lib/main-module.mjs'; // Same stagger as YAHOO_DELAY_MS in scripts/seed-market-quotes.mjs. const YAHOO_DELAY_MS = 200; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); export function chartUrl(symbol) { return `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?range=1mo&interval=1d`; } export function countFiniteCloses(chart) { const closes = chart?.chart?.result?.[0]?.indicators?.quote?.[0]?.close; return Array.isArray(closes) ? closes.filter((value) => Number.isFinite(value)).length : 0; } /** * Classify one declared entry against its probe result. Pure, so the decision * table is unit-testable without touching the network. * * @returns {'ok' | 'flagged-still-dead' | 'dead-but-serviceable' | 'flag-can-lift' | 'probe-error' | 'non-yahoo-source'} */ export function classify(index, { snapshot, error }) { // A MOEX-served entry (#6153) has no Yahoo chart to probe; it is neither // dead nor liftable from this audit's point of view. if (index.source === MOEX_SOURCE) return 'non-yahoo-source'; if (error) return 'probe-error'; if (index.unavailable) return snapshot ? 'flag-can-lift' : 'flagged-still-dead'; return snapshot ? 'ok' : 'dead-but-serviceable'; } /** @returns {0 | 1 | 2} see the exit status table in the file header. */ export function exitCodeFor(rows) { if (rows.some((row) => row.verdict === 'dead-but-serviceable' || row.verdict === 'flag-can-lift')) return 1; if (rows.some((row) => row.verdict === 'probe-error')) return 2; return 0; } export async function auditCountryStockIndexes({ indexes = loadDeclaredCountryStockIndexes(), fetchJson = fetchYahooJson, delayMs = YAHOO_DELAY_MS, } = {}) { const rows = []; for (const index of indexes) { if (index.source === MOEX_SOURCE) { rows.push({ ...index, closes: 0, snapshot: false, error: null, verdict: classify(index, { snapshot: false, error: null }) }); continue; } let probe; try { if (rows.length > 0) await sleep(delayMs); const chart = await fetchJson(chartUrl(index.symbol), { label: `${index.code} country index audit` }); const closes = countFiniteCloses(chart); probe = { closes, snapshot: Boolean(buildCountryStockIndexSnapshot(chart, undefined, index)), error: null }; } catch (err) { probe = { closes: 0, snapshot: false, error: err?.message || String(err) }; } rows.push({ ...index, ...probe, verdict: classify(index, probe) }); } return rows; } function formatRow(row) { const flag = row.unavailable ? `unavailable since ${row.unavailable.checked}` : ''; const detail = row.error ? `error: ${row.error}` : `${row.closes} closes`; return `${row.code.padEnd(3)} ${row.symbol.padEnd(12)} ${detail.padEnd(28)} ${row.verdict.padEnd(22)} ${flag}`; } const codesWith = (rows, verdict) => rows.filter((row) => row.verdict === verdict).map((row) => row.code); async function main() { loadEnvFile(import.meta.url); const rows = await auditCountryStockIndexes(); for (const row of rows) console.log(formatRow(row)); console.log( `\n${rows.length} declared, ${codesWith(rows, 'ok').length} serving, ` + `${rows.filter((row) => row.unavailable).length} flagged unavailable.`, ); const dead = codesWith(rows, 'dead-but-serviceable'); const liftable = codesWith(rows, 'flag-can-lift'); const inconclusive = codesWith(rows, 'probe-error'); if (dead.length > 0) { console.error(`\nServiceable entries Yahoo cannot serve — fix the symbol or flag them: ${dead.join(', ')}`); } if (liftable.length > 0) { console.error(`\nFlagged entries that now return closes — lift the flag: ${liftable.join(', ')}`); } if (inconclusive.length > 0) { console.error(`\nProbes failed, no verdict — re-run before changing any flag: ${inconclusive.join(', ')}`); } process.exitCode = exitCodeFor(rows); } if (isMainModule(import.meta.url, process.argv[1])) { main().catch((err) => { console.error(err); process.exitCode = 1; }); }