/* ============================================================================ * HFChart — couche données : API HF Market Data (https://www.hfmarketdata.io) * Author : Simon-Pierre Boucher — contact@spboucher.ai * ---------------------------------------------------------------------------- * Le navigateur interroge l'API directement (CORS ouvert). Les timeframes * absents du lac sont dérivés côté client par resampling (1h→4h, 1J→1S, * 5min→15m, …). `/v1/status` pilote dynamiquement ce qui est disponible. * ==========================================================================*/ (function () { 'use strict'; const HF = (window.HF = window.HF || {}); const API = window.HFMD_API || 'https://www.hfmarketdata.io'; const MAX_API_LIMIT = 50000; const GROUPS = [ { key: 'index', label: 'Indices' }, { key: 'etf', label: 'ETF' }, { key: 'stock', label: 'Actions' }, { key: 'futures', label: 'Futures' }, { key: 'crypto', label: 'Crypto' }, { key: 'fx', label: 'FX' }, ]; /* tf UI → sources candidates (ordre de préférence) + facteur de resampling */ const TF_SOURCES = { '1': [['1min', 1]], '5': [['5min', 1], ['1min', 5]], '15': [['5min', 3], ['1min', 15]], '30': [['30min', 1], ['5min', 6], ['1min', 30]], '60': [['1hour', 1], ['30min', 2], ['5min', 12]], '240': [['1hour', 4], ['30min', 8]], 'D': [['1day', 1]], 'W': [['1day', 'W']], }; let status = null; // /v1/status → { asset: { tf: { adj: n } } } async function getJSON(url) { const res = await fetch(url); if (!res.ok) { let msg = res.statusText; try { msg = (await res.json()).detail || msg; } catch (e) { /* texte brut */ } throw new Error(msg); } return res.json(); } async function loadStatus() { if (status) return status; status = (await getJSON(`${API}/v1/status`)).datasets || {}; return status; } function srcAvailable(asset, srcTf) { const a = status && status[asset]; if (!a || !a[srcTf]) return false; return Object.values(a[srcTf]).some(n => n > 0); } /* Résout un tf UI vers (source, facteur) selon la disponibilité réelle. */ function resolveTf(asset, uiTf) { for (const [src, factor] of (TF_SOURCES[uiTf] || [])) { if (srcAvailable(asset, src)) return { src, factor }; } return null; } function tfAvailability(asset) { const out = {}; for (const tf of Object.keys(TF_SOURCES)) out[tf] = !!resolveTf(asset, tf); return out; } function parseDT(s) { // "YYYY-MM-DD HH:MM:SS" → epoch (UTC naïf) return Date.UTC(+s.slice(0, 4), +s.slice(5, 7) - 1, +s.slice(8, 10), +(s.slice(11, 13) || 0), +(s.slice(14, 16) || 0), +(s.slice(17, 19) || 0)) / 1000; } function resampleSec(bars, sec) { const out = []; let cur = null, key = null; for (const b of bars) { const k = b.t - (b.t % sec); if (k !== key) { if (cur) out.push(cur); key = k; cur = { t: k, o: b.o, h: b.h, l: b.l, c: b.c, v: b.v }; } else { cur.h = Math.max(cur.h, b.h); cur.l = Math.min(cur.l, b.l); cur.c = b.c; cur.v += b.v; } } if (cur) out.push(cur); return out; } function resampleWeekly(daily) { const out = []; let cur = null, key = null; for (const b of daily) { const wd = (Math.floor(b.t / 86400) + 3) % 7; // lundi = 0 const k = b.t - wd * 86400; if (k !== key) { if (cur) out.push(cur); key = k; cur = { t: k, o: b.o, h: b.h, l: b.l, c: b.c, v: b.v }; } else { cur.h = Math.max(cur.h, b.h); cur.l = Math.min(cur.l, b.l); cur.c = b.c; cur.v += b.v; } } if (cur) out.push(cur); return out; } function detectDecimals(bars) { let dec = 0; for (let i = Math.max(0, bars.length - 80); i < bars.length; i++) { const c = bars[i].c; for (let d = dec; d <= 6; d++) { if (Math.abs(c * Math.pow(10, d) - Math.round(c * Math.pow(10, d))) < 1e-6) { dec = Math.max(dec, d); break; } if (d === 6) dec = 6; } } return dec; } const tickerCache = new Map(); async function tickers(asset) { if (tickerCache.has(asset)) return tickerCache.get(asset); await loadStatus(); // n'importe quel tf disponible fait l'affaire pour lister const tfs = Object.keys(status[asset] || {}); const tf = tfs.includes('1day') ? '1day' : tfs[0]; if (!tf) return []; const j = await getJSON(`${API}/v1/${asset}/tickers?timeframe=${tf}&limit=20000`); const list = (j.tickers || []).filter(t => t && t.trim()); tickerCache.set(asset, list); return list; } const barCache = new Map(); /* Retourne {parsed:[{t,o,h,l,c,v}], hasVolume, decimals, srcLimitReached} */ async function bars(asset, ticker, uiTf, wantBars) { await loadStatus(); const r = resolveTf(asset, uiTf); if (!r) throw new Error(`Timeframe indisponible pour ${asset} (lac de données)`); const factor = r.factor === 'W' ? 5 : r.factor; const apiLimit = Math.min(MAX_API_LIMIT, Math.ceil(wantBars * factor * 1.1)); const key = `${asset}|${ticker}|${r.src}|${apiLimit}`; if (barCache.has(key)) return barCache.get(key); const j = await getJSON( `${API}/v1/bars/${asset}/${encodeURIComponent(ticker)}?timeframe=${r.src}&order=desc&limit=${apiLimit}`); const rows = (j.data || []).reverse(); let parsed = rows.map(rec => ({ t: parseDT(rec.datetime), o: rec.open, h: rec.high, l: rec.low, c: rec.close, v: rec.volume != null ? rec.volume : 0, })); if (r.factor === 'W') parsed = resampleWeekly(parsed); else if (r.factor > 1) { const secOf = { '1min': 60, '5min': 300, '30min': 1800, '1hour': 3600 }; parsed = resampleSec(parsed, secOf[r.src] * r.factor); } const out = { parsed, hasVolume: rows.some(rec => rec.volume != null && rec.volume > 0), decimals: detectDecimals(parsed), srcLimitReached: (j.count || rows.length) >= apiLimit && apiLimit >= MAX_API_LIMIT, src: r.src, }; if (barCache.size > 24) barCache.delete(barCache.keys().next().value); barCache.set(key, out); return out; } HF.data = { API, GROUPS, loadStatus, tfAvailability, resolveTf, tickers, bars, resampleSec, resampleWeekly, MAX_API_LIMIT }; })();