feat(options-lab): client API options — tickers/expirations/chaîne/history/spot, cache LRU, lazy par échéance
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 1 changed file with +135 and −0
added
web/js/options-data.js
+135 −0
@@ -0,0 +1,135 @@ | ||
| 1 | +/* ============================================================================ | |
| 2 | + * HFChart Options Lab — client API options (HF Market Data) | |
| 3 | + * Author : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | + * ---------------------------------------------------------------------------- | |
| 5 | + * Extension du client HF.data existant pour les endpoints /v1/options/*. | |
| 6 | + * Caches par (ticker, date) et (ticker, date, expiry) — les chaînes sont | |
| 7 | + * volumineuses, le chargement est paresseux par échéance. | |
| 8 | + * ==========================================================================*/ | |
| 9 | +(function () { | |
| 10 | + 'use strict'; | |
| 11 | + const HF = (window.HF = window.HF || {}); | |
| 12 | + const API = window.HFMD_API || 'https://www.hfmarketdata.io'; | |
| 13 | + | |
| 14 | + async function getJSON(url) { | |
| 15 | + const res = await fetch(url); | |
| 16 | + if (!res.ok) { | |
| 17 | + let msg = res.statusText; | |
| 18 | + try { msg = (await res.json()).detail || msg; } catch (e) { /* texte brut */ } | |
| 19 | + const err = new Error(msg); | |
| 20 | + err.status = res.status; | |
| 21 | + throw err; | |
| 22 | + } | |
| 23 | + return res.json(); | |
| 24 | + } | |
| 25 | + | |
| 26 | + /* Cache LRU minimal partagé par toutes les requêtes options. */ | |
| 27 | + const cache = new Map(); | |
| 28 | + const CACHE_MAX = 60; | |
| 29 | + async function cached(key, fetcher) { | |
| 30 | + if (cache.has(key)) return cache.get(key); | |
| 31 | + const value = await fetcher(); | |
| 32 | + if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value); | |
| 33 | + cache.set(key, value); | |
| 34 | + return value; | |
| 35 | + } | |
| 36 | + | |
| 37 | + /* Sous-jacents disponibles (dernier trimestre). */ | |
| 38 | + function tickers(search) { | |
| 39 | + const q = search ? `&search=${encodeURIComponent(search.toUpperCase())}` : ''; | |
| 40 | + return cached(`tick|${search || ''}`, async () => | |
| 41 | + (await getJSON(`${API}/v1/options/tickers?limit=12000${q}`)).tickers || []); | |
| 42 | + } | |
| 43 | + | |
| 44 | + /* Échéances disponibles pour un ticker à une date de marché donnée. | |
| 45 | + * Retourne [] si le jour est férié / sans données. */ | |
| 46 | + function expirations(ticker, tradeDate) { | |
| 47 | + const q = tradeDate ? `?trade_date=${tradeDate}` : ''; | |
| 48 | + return cached(`exp|${ticker}|${tradeDate || ''}`, async () => { | |
| 49 | + try { | |
| 50 | + return (await getJSON(`${API}/v1/options/expirations/${encodeURIComponent(ticker)}${q}`)).expirations || []; | |
| 51 | + } catch (e) { | |
| 52 | + if (e.status === 404) return []; | |
| 53 | + throw e; | |
| 54 | + } | |
| 55 | + }); | |
| 56 | + } | |
| 57 | + | |
| 58 | + /* Chaîne d'une seule échéance à une date donnée (chargement paresseux). | |
| 59 | + * Sans tradeDate, l'API renvoie la dernière date disponible. */ | |
| 60 | + function chain(ticker, tradeDate, expiry) { | |
| 61 | + const params = new URLSearchParams({ limit: '6000' }); | |
| 62 | + if (tradeDate) params.set('trade_date', tradeDate); | |
| 63 | + if (expiry) params.set('expiry', expiry); | |
| 64 | + return cached(`chain|${ticker}|${tradeDate || 'latest'}|${expiry || 'all'}`, async () => { | |
| 65 | + try { | |
| 66 | + const j = await getJSON(`${API}/v1/options/chain/${encodeURIComponent(ticker)}?${params}`); | |
| 67 | + return j.data || []; | |
| 68 | + } catch (e) { | |
| 69 | + if (e.status === 404) return []; | |
| 70 | + throw e; | |
| 71 | + } | |
| 72 | + }); | |
| 73 | + } | |
| 74 | + | |
| 75 | + /* Dernière date de marché disponible pour un ticker (sonde légère). */ | |
| 76 | + function latestTradeDate(ticker) { | |
| 77 | + return cached(`latest|${ticker}`, async () => { | |
| 78 | + const rows = await chain(ticker, null, null); | |
| 79 | + return rows.length ? rows[0].trade_date : null; | |
| 80 | + }); | |
| 81 | + } | |
| 82 | + | |
| 83 | + /* Série journalière complète d'un contrat (pour le replay mark-to-market). */ | |
| 84 | + function history(ticker, strike, expiry, callPut) { | |
| 85 | + const params = new URLSearchParams({ | |
| 86 | + strike: String(strike), expiry, call_put: callPut, limit: '5000', | |
| 87 | + }); | |
| 88 | + return cached(`hist|${ticker}|${strike}|${expiry}|${callPut}`, async () => { | |
| 89 | + try { | |
| 90 | + const j = await getJSON(`${API}/v1/options/history/${encodeURIComponent(ticker)}?${params}`); | |
| 91 | + return j.data || []; | |
| 92 | + } catch (e) { | |
| 93 | + if (e.status === 404) return []; | |
| 94 | + throw e; | |
| 95 | + } | |
| 96 | + }); | |
| 97 | + } | |
| 98 | + | |
| 99 | + const SPOT_ASSETS = ['etf', 'stock', 'index']; | |
| 100 | + | |
| 101 | + /* Barres journalières du sous-jacent sur [start, end] — la classe d'actif | |
| 102 | + * n'est pas connue : essais etf → stock → index (mémorisé par ticker). */ | |
| 103 | + const assetOf = new Map(); | |
| 104 | + async function spotBars(ticker, start, end) { | |
| 105 | + const tryAssets = assetOf.has(ticker) ? [assetOf.get(ticker)] : SPOT_ASSETS; | |
| 106 | + for (const asset of tryAssets) { | |
| 107 | + try { | |
| 108 | + const params = new URLSearchParams({ | |
| 109 | + timeframe: '1day', order: 'asc', limit: '4000', start, end, | |
| 110 | + }); | |
| 111 | + const j = await getJSON(`${API}/v1/bars/${asset}/${encodeURIComponent(ticker)}?${params}`); | |
| 112 | + if (j.data && j.data.length) { | |
| 113 | + assetOf.set(ticker, asset); | |
| 114 | + return { asset, bars: j.data }; | |
| 115 | + } | |
| 116 | + } catch (e) { | |
| 117 | + if (e.status !== 404) throw e; | |
| 118 | + } | |
| 119 | + } | |
| 120 | + return { asset: null, bars: [] }; | |
| 121 | + } | |
| 122 | + | |
| 123 | + /* Spot de clôture à une date donnée (dernière barre ≤ date). */ | |
| 124 | + async function spotOn(ticker, dateISO) { | |
| 125 | + const from = new Date(dateISO + 'T00:00:00Z'); | |
| 126 | + from.setUTCDate(from.getUTCDate() - 10); // marge pour week-ends/fériés | |
| 127 | + const start = from.toISOString().slice(0, 10); | |
| 128 | + const { asset, bars } = await spotBars(ticker, start, dateISO + ' 23:59:59'); | |
| 129 | + if (!bars.length) return null; | |
| 130 | + const last = bars[bars.length - 1]; | |
| 131 | + return { asset, date: String(last.datetime).slice(0, 10), close: last.close }; | |
| 132 | + } | |
| 133 | + | |
| 134 | + HF.optdata = { tickers, expirations, chain, latestTradeDate, history, spotBars, spotOn, API }; | |
| 135 | +})(); | |
| 136 | ||