scripts/_country-stock-index-moex.mjs
Rama main · réplica de lectura del repositorio original.
// @ts-check
//
// Moscow Exchange ISS leg of the country stock-index seeder (#6153).
//
// Yahoo Finance stopped serving MOEX data after 2022, so the RU entry in the
// public country enum was flagged `unavailable` (#6240). ISS is the exchange's
// own API: keyless, no registration, 15-minute delayed, UTF-8 JSON. The
// endpoints return columnar `{ columns, data }` tables, so every cell
// is read by column NAME, never by position, and an upstream column insertion
// cannot shift a field silently.
//
// Deliberately free of filesystem reads, like _country-stock-index.mjs, and
// free of network calls: the seeder injects fetch so this stays unit-testable.
import { buildCountryStockIndexSnapshotFromCloses } from './_country-stock-index.mjs';
export const MOEX_SOURCE = 'moex-iss';
export const MOEX_CURRENCY = 'RUB';
export const MOEX_HISTORY_DAYS = 45; // enough trading days for the 6-close weekly window across holidays
export const MOEX_USER_AGENT = 'WorldMonitor country-index seeder (+https://worldmonitor.app)';
const MOEX_ISS_ORIGIN = 'https://iss.moex.com';
const MOEX_TIMEOUT_MS = 15_000;
/** @param {string} symbol @param {Date} [now] */
export function moexHistoryUrl(symbol, now = new Date()) {
const from = new Date(now.getTime() - MOEX_HISTORY_DAYS * 24 * 3600 * 1000).toISOString().slice(0, 10);
const till = now.toISOString().slice(0, 10);
const params = new URLSearchParams({
'iss.meta': 'off',
'iss.only': 'history',
'history.columns': 'TRADEDATE,CLOSE',
from,
till,
});
return `${MOEX_ISS_ORIGIN}/iss/history/engines/stock/markets/index/securities/${encodeURIComponent(symbol)}.json?${params.toString()}`;
}
/** @param {unknown} payload @param {string} name */
function moexTable(payload, name) {
if (!payload || typeof payload !== 'object') return null;
const table = /** @type {Record<string, unknown>} */ (payload)[name];
if (!table || typeof table !== 'object' || !('columns' in table) || !('data' in table)) return null;
if (!Array.isArray(table.columns) || !Array.isArray(table.data)) return null;
return { columns: table.columns, data: table.data };
}
/** @param {unknown} value */
function positivePrice(value) {
return (typeof value === 'number' || (typeof value === 'string' && value.trim() !== ''))
&& Number.isFinite(Number(value)) && Number(value) > 0;
}
/** @param {unknown} payload @param {string} [beforeDate] @returns {number[]} */
export function parseMoexHistoryCloses(payload, beforeDate) {
const history = moexTable(payload, 'history');
if (!history) return [];
const dateAt = history.columns.indexOf('TRADEDATE');
const closeAt = history.columns.indexOf('CLOSE');
if (dateAt === -1 || closeAt === -1) return [];
return history.data
.filter((row) => Array.isArray(row) && typeof row[dateAt] === 'string'
&& (!beforeDate || row[dateAt] < beforeDate) && positivePrice(row[closeAt]))
.sort((a, b) => String(a[dateAt]).localeCompare(String(b[dateAt])))
.map((row) => Number(row[closeAt]));
}
/** @param {string} symbol */
export function moexMarketdataUrl(symbol) {
const params = new URLSearchParams({
'iss.meta': 'off', 'iss.only': 'marketdata',
'marketdata.columns': 'SECID,CURRENTVALUE,TRADEDATE,UPDATETIME',
});
return `${MOEX_ISS_ORIGIN}/iss/engines/stock/markets/index/securities/${encodeURIComponent(symbol)}.json?${params.toString()}`;
}
/** @param {unknown} payload @param {string} symbol */
function currentIndex(payload, symbol) {
const market = moexTable(payload, 'marketdata');
if (!market) return null;
const symbolAt = market.columns.indexOf('SECID');
const priceAt = market.columns.indexOf('CURRENTVALUE');
const dateAt = market.columns.indexOf('TRADEDATE');
if (symbolAt < 0 || priceAt < 0 || dateAt < 0) return null;
const row = market.data.find((row) => Array.isArray(row) && row[symbolAt] === symbol);
if (!row || !positivePrice(row[priceAt]) || typeof row[dateAt] !== 'string'
|| !/^\d{4}-\d{2}-\d{2}$/.test(row[dateAt]) || !Number.isFinite(Date.parse(row[dateAt]))) return null;
return { price: Number(row[priceAt]), tradeDate: row[dateAt] };
}
/**
* Current delayed quote plus the preceding daily closes. History can already
* contain today's close, so exclude that date before appending the quote.
* @param {unknown} history
* @param {unknown} marketdata
* @param {{ code: string, symbol: string, name: string }} index
* @param {string} [fetchedAt]
*/
export function buildMoexCountryStockIndexSnapshot(history, marketdata, index, fetchedAt = new Date().toISOString()) {
const current = currentIndex(marketdata, index.symbol);
if (!current) return null;
const closes = parseMoexHistoryCloses(history, current.tradeDate);
return buildCountryStockIndexSnapshotFromCloses([...closes, current.price], MOEX_CURRENCY, fetchedAt, index);
}
/**
* @param {{ code: string, symbol: string, name: string }} index
* @param {{ fetchImpl?: typeof fetch, now?: Date }} [options]
*/
export async function fetchMoexCountryStockIndexSnapshot(index, { fetchImpl = (...args) => globalThis.fetch(...args), now = new Date() } = {}) {
const [history, marketdata] = await Promise.all(
[moexHistoryUrl(index.symbol, now), moexMarketdataUrl(index.symbol)].map(async (url) => {
const response = await fetchImpl(url, {
headers: { 'User-Agent': MOEX_USER_AGENT, Accept: 'application/json' },
signal: AbortSignal.timeout(MOEX_TIMEOUT_MS),
});
if (!response.ok) throw new Error(`MOEX ISS HTTP ${response.status}`);
return response.json();
}),
);
const snapshot = buildMoexCountryStockIndexSnapshot(history, marketdata, index, now.toISOString());
if (!snapshot) throw new Error('insufficient market data');
return snapshot;
}