mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* test: rewrite transit chart test as structural contract verification Replace fragile source-string extraction + new Function() compilation with structural pattern checks on the source code. Tests verify: - render() clears chart before content change - clearTransitChart() cancels timer, disconnects observer, destroys chart - MutationObserver setup for DOM readiness detection - Fallback timer for no-op renders (100-500ms range) - Both callbacks (observer + timer) clean up each other - Tab switch and collapse clear chart state - Mount function guards against missing element/data Replaces PR #1634's approach which was brittle (method body extraction, TypeScript cast stripping, sandboxed execution). * fix: log fetch error cause in seed retry and FATAL handlers Node 20 fetch() throws TypeError('fetch failed') with the real error hidden in err.cause (DNS, TLS, timeout). The current logging only shows 'fetch failed' which is useless for diagnosis. Now logs err.cause.message in both withRetry() retries and FATAL catch blocks.
150 lines
4.4 KiB
JavaScript
Executable File
150 lines
4.4 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
import { loadEnvFile, loadSharedConfig, CHROME_UA, runSeed } from './_seed-utils.mjs';
|
|
|
|
const etfConfig = loadSharedConfig('etfs.json');
|
|
|
|
loadEnvFile(import.meta.url);
|
|
|
|
const CANONICAL_KEY = 'market:etf-flows:v1';
|
|
const CACHE_TTL = 3600;
|
|
const YAHOO_DELAY_MS = 200;
|
|
|
|
const ETF_LIST = etfConfig.btcSpot;
|
|
|
|
function sleep(ms) {
|
|
return new Promise((r) => setTimeout(r, ms));
|
|
}
|
|
|
|
async function fetchYahooWithRetry(url, label, maxAttempts = 4) {
|
|
for (let i = 0; i < maxAttempts; i++) {
|
|
const resp = await fetch(url, {
|
|
headers: { 'User-Agent': CHROME_UA },
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
if (resp.status === 429) {
|
|
const wait = 5000 * (i + 1);
|
|
console.warn(` [Yahoo] ${label} 429 — waiting ${wait / 1000}s (attempt ${i + 1}/${maxAttempts})`);
|
|
await sleep(wait);
|
|
continue;
|
|
}
|
|
if (!resp.ok) {
|
|
console.warn(` [Yahoo] ${label} HTTP ${resp.status}`);
|
|
return null;
|
|
}
|
|
return resp;
|
|
}
|
|
console.warn(` [Yahoo] ${label} rate limited after ${maxAttempts} attempts`);
|
|
return null;
|
|
}
|
|
|
|
function parseEtfChartData(chart, ticker, issuer) {
|
|
const result = chart?.chart?.result?.[0];
|
|
if (!result) return null;
|
|
|
|
const quote = result.indicators?.quote?.[0];
|
|
const closes = quote?.close || [];
|
|
const volumes = quote?.volume || [];
|
|
|
|
const validCloses = closes.filter((p) => p != null);
|
|
const validVolumes = volumes.filter((v) => v != null);
|
|
|
|
if (validCloses.length < 2) return null;
|
|
|
|
const latestPrice = validCloses[validCloses.length - 1];
|
|
const prevPrice = validCloses[validCloses.length - 2];
|
|
const priceChange = prevPrice ? ((latestPrice - prevPrice) / prevPrice) * 100 : 0;
|
|
|
|
const latestVolume = validVolumes.length > 0 ? validVolumes[validVolumes.length - 1] : 0;
|
|
const avgVolume =
|
|
validVolumes.length > 1
|
|
? validVolumes.slice(0, -1).reduce((a, b) => a + b, 0) / (validVolumes.length - 1)
|
|
: latestVolume;
|
|
|
|
const volumeRatio = avgVolume > 0 ? latestVolume / avgVolume : 1;
|
|
const direction = priceChange > 0.1 ? 'inflow' : priceChange < -0.1 ? 'outflow' : 'neutral';
|
|
const estFlowMagnitude = latestVolume * latestPrice * (priceChange > 0 ? 1 : -1) * 0.1;
|
|
|
|
return {
|
|
ticker,
|
|
issuer,
|
|
price: +latestPrice.toFixed(2),
|
|
priceChange: +priceChange.toFixed(2),
|
|
volume: latestVolume,
|
|
avgVolume: Math.round(avgVolume),
|
|
volumeRatio: +volumeRatio.toFixed(2),
|
|
direction,
|
|
estFlow: Math.round(estFlowMagnitude),
|
|
};
|
|
}
|
|
|
|
async function fetchEtfFlows() {
|
|
const etfs = [];
|
|
let misses = 0;
|
|
|
|
for (let i = 0; i < ETF_LIST.length; i++) {
|
|
const { ticker, issuer } = ETF_LIST[i];
|
|
if (i > 0) await sleep(YAHOO_DELAY_MS);
|
|
|
|
try {
|
|
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${ticker}?range=5d&interval=1d`;
|
|
const resp = await fetchYahooWithRetry(url, ticker);
|
|
if (!resp) {
|
|
misses++;
|
|
continue;
|
|
}
|
|
const chart = await resp.json();
|
|
const parsed = parseEtfChartData(chart, ticker, issuer);
|
|
if (parsed) {
|
|
etfs.push(parsed);
|
|
console.log(` ${ticker}: $${parsed.price} (${parsed.direction})`);
|
|
} else {
|
|
misses++;
|
|
}
|
|
} catch (err) {
|
|
console.warn(` [Yahoo] ${ticker} error: ${err.message}`);
|
|
misses++;
|
|
}
|
|
|
|
if (misses >= 3 && etfs.length === 0) break;
|
|
}
|
|
|
|
if (etfs.length === 0) {
|
|
throw new Error(`All ETF fetches failed (${misses} misses)`);
|
|
}
|
|
|
|
const totalVolume = etfs.reduce((sum, e) => sum + e.volume, 0);
|
|
const totalEstFlow = etfs.reduce((sum, e) => sum + e.estFlow, 0);
|
|
const inflowCount = etfs.filter((e) => e.direction === 'inflow').length;
|
|
const outflowCount = etfs.filter((e) => e.direction === 'outflow').length;
|
|
|
|
etfs.sort((a, b) => b.volume - a.volume);
|
|
|
|
return {
|
|
timestamp: new Date().toISOString(),
|
|
summary: {
|
|
etfCount: etfs.length,
|
|
totalVolume,
|
|
totalEstFlow,
|
|
netDirection: totalEstFlow > 0 ? 'NET INFLOW' : totalEstFlow < 0 ? 'NET OUTFLOW' : 'NEUTRAL',
|
|
inflowCount,
|
|
outflowCount,
|
|
},
|
|
etfs,
|
|
rateLimited: false,
|
|
};
|
|
}
|
|
|
|
function validate(data) {
|
|
return Array.isArray(data?.etfs) && data.etfs.length >= 1;
|
|
}
|
|
|
|
runSeed('market', 'etf-flows', CANONICAL_KEY, fetchEtfFlows, {
|
|
validateFn: validate,
|
|
ttlSeconds: CACHE_TTL,
|
|
sourceVersion: 'yahoo-chart-5d',
|
|
}).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);
|
|
});
|