SPB Git

spb/hfchart

Public

HFChart — la référence des charts haute fréquence : 14 types rendus canvas from scratch (zéro dépendance), données HF Market Data — www.hfchart.io

feat: initial commit

simon-pierre boucher committed (Aug 10, 2026) Browse files

19 changed files +3,409 −0

added .gitignore +8 −0
@@ -0,0 +1,8 @@
1+# Échantillons de données brutes 1-min (12 Go) — la plateforme vit sur l'API hfmarketdata.io
2+index_full_1min_*/
3+etf_*_full_1min_*/
4+.hfchart_cache/
5+
6+.DS_Store
7+.claude/
8+*.log
added README.md +161 −0
@@ -0,0 +1,161 @@
1+# HFChart
2+
3+**La référence des charts financiers haute fréquence — moteur canvas écrit from scratch, zéro dépendance.**
4+
5+[![Live](https://img.shields.io/badge/live-www.hfchart.io-2a78d6)](https://www.hfchart.io)
6+[![Données](https://img.shields.io/badge/donn%C3%A9es-hfmarketdata.io-1baf7a)](https://www.hfmarketdata.io)
7+[![Dépendances](https://img.shields.io/badge/d%C3%A9pendances-0-008300)](#architecture)
8+[![Types de charts](https://img.shields.io/badge/types_de_charts-14-eb6834)](#le-catalogue)
9+[![Indicateurs](https://img.shields.io/badge/indicateurs-8-eda100)](#indicateurs)
10+[![Embed gzip](https://img.shields.io/badge/embed.js-18.3_Ko_gzip-4a3aa7)](#embed--snippet-autonome)
11+[![Mobile](https://img.shields.io/badge/mobile-tactile_%2B_pinch--zoom-e87ba4)](#interactions)
12+[![Python](https://img.shields.io/badge/serveur-Python_stdlib-3776ab)](#architecture)
13+[![CVD safe](https://img.shields.io/badge/daltonisme-sch%C3%A9ma_valid%C3%A9_ΔE_19.2-52514e)](#couleurs)
14+
15+[![Author](https://img.shields.io/badge/author-Simon--Pierre_Boucher-0b0b0b)](https://www.spboucher.ai)
16+[![Contact](https://img.shields.io/badge/contact-contact%40spboucher.ai-d03b3b)](mailto:contact@spboucher.ai)
17+
18+![HFChart — thème clair](docs/screenshot-light.png)
19+
20+Plateforme-catalogue de graphiques financiers haute fréquence : chaque pixel est
21+dessiné à la main sur un canvas 2D — **pas de TradingView, pas de lib de charts,
22+pas de framework**. Les données proviennent de l'API ouverte
23+[HF Market Data](https://www.hfmarketdata.io) (interrogée directement par le
24+navigateur), qui couvre **6 classes d'actifs et ~13 000 instruments**.
25+
26+---
27+
28+## Métriques
29+
30+| Métrique | Valeur |
31+|---|---|
32+| Dépendances (front + serveur) | **0** |
33+| Lignes de code totales | **3 240** |
34+| — moteur de rendu (`engine.js`) | 867 |
35+| — application (`app.js`) | 688 |
36+| — renderers (`renderers.js`) | 348 |
37+| — transformations (`transforms.js`) | 208 |
38+| — client API (`data.js`) | 185 |
39+| — indicateurs (`indicators.js`) | 164 |
40+| — bootstrapper embed (`embed-boot.js`) | 151 |
41+| — serveur statique (`server.py`, stdlib pur) | 109 |
42+| Bundle embed complet (`/embed.js`) | 68,5 Ko brut · **18,3 Ko gzip** |
43+| Types de charts | **14** |
44+| Indicateurs techniques | **8** |
45+| Classes d'actifs | 6 (indices, ETF, actions, futures, crypto, FX) |
46+| Instruments accessibles | ~13 200 |
47+| Thèmes | clair (défaut) + sombre, sélectionnés — pas dérivés |
48+| Schémas hausse/baisse | vert/rouge classique + bleu/rouge validé daltonisme |
49+
50+## Le catalogue
51+
52+![Galerie des 14 types](docs/screenshot-gallery.png)
53+
54+| Type | Rendu |
55+|---|---|
56+| **Chandelles** | corps pleins, mèches, fast-path haute densité (< 2 px/barre → traits hi-lo) |
57+| **Chandelles creuses** | hausse creuse / baisse pleine — lisible sans la couleur |
58+| **Barres OHLC** | ticks open/close |
59+| **Ligne · Escalier · Aire · Baseline** | close 2 px, marches, dégradé 3 arrêts, ancre ± |
60+| **Heikin-Ashi** | chandelles lissées de tendance |
61+| **Renko** | briques à taille fixe, auto-dimensionnées par ATR, personnalisables |
62+| **Three-Line Break** | nouvelle barre sur cassure des N dernières |
63+| **Range Bars** | chaque barre couvre exactement un range fixe |
64+| **Kagi** | yang épais / yin fin, renversement fixe |
65+| **Point & Figure** | colonnes de X et O, renversement en boîtes |
66+| **Comparaison %** | jusqu'à 4 symboles, **rebasage dynamique sur la 1ʳᵉ barre visible** |
67+
68+![Renko](docs/screenshot-renko.png)
69+![Comparaison %](docs/screenshot-compare.png)
70+
71+## Indicateurs
72+
73+SMA · EMA · Bollinger · VWAP de session ±2σ · Profil de volume (POC) —
74+en overlay, et RSI · MACD · ATR en panes dédiées. Toutes les périodes sont
75+réglables (menu Réglages). Chaque calcul est implémenté from scratch dans
76+`indicators.js` (Wilder pour RSI/ATR, σ cumulée pour les bandes VWAP).
77+
78+![Thème sombre + indicateurs](docs/screenshot-dark-indicators.png)
79+
80+## Interactions
81+
82+- Molette = zoom ancré au curseur · glisser = pan · double-clic = reset
83+- **Tactile** : pan au doigt, **pinch-zoom à deux doigts**, drawer de symboles
84+- Crosshair avec lecture OHLCV + variation %, chips de prix/temps
85+- Échelle logarithmique, extension automatique de l'historique au bord gauche
86+- Vue table (accessibilité), galerie live des types, état persistant
87+- **Vues partageables par URL** :
88+
89+```
90+https://www.hfchart.io/?group=etf&symbol=SPY&tf=60&type=renko&inds=rsi,vwap&theme=dark
91+https://www.hfchart.io/?group=index&symbol=SPX&tf=D&type=compare&compare=NDX,DJI,RUT
92+https://www.hfchart.io/?panel=gallery
93+```
94+
95+![Mobile 390 px](docs/screenshot-mobile.png)
96+
97+## Embed — snippet autonome
98+
99+Le bouton **Exporter** génère un snippet HTML **avec les données intégrées**
100+(barres visibles, type, indicateurs, réglages) : le graphe collé dans n'importe
101+quelle page est pleinement interactif (zoom, pan, crosshair, panes) et ne fait
102+**aucune requête réseau** pour les données. Un seul script :
103+
104+```html
105+<div id="hfchart-x7k2" style="width:100%;height:420px"></div>
106+<script>
107+(window.HFChartEmbedQueue = window.HFChartEmbedQueue || []).push({
108+ el: "#hfchart-x7k2",
109+ config: { symbol: "SPX", tf: "1h", type: "candles", theme: "light",
110+ inds: ["sma20","rsi"], decimals: 2, hasVolume: true,
111+ bars: [[1700000000, 100.0, 101.2, 99.8, 100.9, 12000], ...] }
112+});
113+</script>
114+<script async src="https://www.hfchart.io/embed.js"></script>
115+```
116+
117+Une variante **iframe live** (`?embed=1`) est aussi proposée.
118+
119+![Embed autonome dans une page](docs/screenshot-embed.png)
120+
121+## Couleurs
122+
123+Le schéma hausse/baisse classique (vert `#0ca30c` / rouge `#d03b3b`) est doublé
124+d'un schéma **« Bleu / rouge (daltonisme) »** dont la paire passe tous les
125+checks du validateur de palette (CVD ΔE 19,2 ; contraste ≥ 3:1 sur les deux
126+surfaces). Les séries d'indicateurs et de comparaison suivent un ordre
127+catégoriel fixe validé — jamais recyclé.
128+
129+## Architecture
130+
131+```
132+server.py hôte statique (Python stdlib) + route /embed.js (bundle concaténé, cache mtime)
133+web/index.html shell de l'app
134+web/css/style.css tokens clair/sombre + layout responsive (drawer mobile)
135+web/js/engine.js moteur canvas : panes, échelles lin/log, axes, crosshair, zoom/pan/pinch
136+web/js/renderers.js dessin des 14 types + colonnes de volume + bandes + profil
137+web/js/transforms.js Heikin-Ashi, Renko, Kagi, PnF, Line Break, Range Bars
138+web/js/indicators.js SMA/EMA/Bollinger/RSI/MACD/ATR/VWAP/profil de volume
139+web/js/data.js client API hfmarketdata.io + resampling client (1h→4h, 1J→1S, 5min→15m)
140+web/js/app.js état, UI, comparaison, galerie, export snippet
141+web/js/embed-boot.js bootstrapper des graphes intégrés
142+```
143+
144+Les timeframes absents du lac de données sont dérivés côté client, et les
145+boutons s'activent automatiquement via `/v1/status` quand le lac se remplit.
146+
147+## Lancer
148+
149+```bash
150+python3 server.py 8787 # aucune dépendance à installer
151+# → http://localhost:8787
152+```
153+
154+## Déploiement
155+
156+Production : **https://www.hfchart.io** — nœud MacLustr `M4M64b`, PM2
157+(`hfchart-web` + `hfchart-ngrok`), tunnel ngrok sur domaine custom.
158+
159+---
160+
161+**Author : Simon-Pierre Boucher** · [contact@spboucher.ai](mailto:contact@spboucher.ai) · [spboucher.ai](https://www.spboucher.ai)
added docs/screenshot-compare.png +0 −0

Binary file not shown.

added docs/screenshot-dark-indicators.png +0 −0

Binary file not shown.

added docs/screenshot-embed.png +0 −0

Binary file not shown.

added docs/screenshot-gallery.png +0 −0

Binary file not shown.

added docs/screenshot-light.png +0 −0

Binary file not shown.

added docs/screenshot-mobile.png +0 −0

Binary file not shown.

added docs/screenshot-renko.png +0 −0

Binary file not shown.

added server.py +109 −0
@@ -0,0 +1,109 @@
1+#!/usr/bin/env python3
2+# -*- coding: utf-8 -*-
3+"""
4+================================================================================
5+ HFChart — plateforme de référence des charts haute fréquence
6+ Hôte statique minimal (stdlib uniquement).
7+
8+ Les données des graphs proviennent de l'API HF Market Data :
9+ https://www.hfmarketdata.io (interrogée directement par le navigateur)
10+
11+ Author : Simon-Pierre Boucher
12+ Contact : contact@spboucher.ai
13+================================================================================
14+
15+ python3 server.py [port] # défaut: 8787
16+"""
17+import os
18+import sys
19+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
20+from urllib.parse import urlparse
21+
22+ROOT = os.path.dirname(os.path.abspath(__file__))
23+WEB = os.path.join(ROOT, "web")
24+
25+MIME = {
26+ ".html": "text/html; charset=utf-8",
27+ ".js": "application/javascript; charset=utf-8",
28+ ".css": "text/css; charset=utf-8",
29+ ".json": "application/json; charset=utf-8",
30+ ".svg": "image/svg+xml",
31+ ".png": "image/png",
32+ ".ico": "image/x-icon",
33+ ".webmanifest": "application/manifest+json",
34+}
35+
36+
37+# /embed.js = moteur complet concaténé (transforms + indicateurs + renderers +
38+# engine + bootstrapper d'embed) — un seul <script> pour les snippets exportés.
39+EMBED_PARTS = ["js/transforms.js", "js/indicators.js", "js/renderers.js",
40+ "js/engine.js", "js/embed-boot.js"]
41+_embed_cache = {"key": None, "body": b""}
42+
43+
44+def embed_js():
45+ paths = [os.path.join(WEB, p) for p in EMBED_PARTS]
46+ key = tuple(os.path.getmtime(p) for p in paths)
47+ if _embed_cache["key"] != key:
48+ chunks = [b"/* HFChart embed bundle - Simon-Pierre Boucher - "
49+ b"contact@spboucher.ai - https://www.hfchart.io */\n"]
50+ for p in paths:
51+ with open(p, "rb") as fh:
52+ chunks.append(fh.read())
53+ chunks.append(b"\n;\n")
54+ _embed_cache["key"] = key
55+ _embed_cache["body"] = b"".join(chunks)
56+ return _embed_cache["body"]
57+
58+
59+class Handler(BaseHTTPRequestHandler):
60+ protocol_version = "HTTP/1.1"
61+
62+ def log_message(self, fmt, *args):
63+ sys.stderr.write("[hfchart] %s\n" % (fmt % args))
64+
65+ def _send(self, code, body, mime="text/plain; charset=utf-8", cache="no-cache"):
66+ self.send_response(code)
67+ self.send_header("Content-Type", mime)
68+ self.send_header("Content-Length", str(len(body)))
69+ self.send_header("Cache-Control", cache)
70+ self.end_headers()
71+ self.wfile.write(body)
72+
73+ def do_GET(self):
74+ try:
75+ path = urlparse(self.path).path
76+ if path == "/health":
77+ return self._send(200, b'{"status":"ok","service":"hfchart"}',
78+ "application/json; charset=utf-8")
79+ if path == "/embed.js":
80+ self.send_response(200)
81+ self.send_header("Content-Type", MIME[".js"])
82+ body = embed_js()
83+ self.send_header("Content-Length", str(len(body)))
84+ self.send_header("Cache-Control", "public, max-age=300")
85+ self.send_header("Access-Control-Allow-Origin", "*")
86+ self.end_headers()
87+ self.wfile.write(body)
88+ return None
89+ if path == "/":
90+ path = "/index.html"
91+ fp = os.path.normpath(os.path.join(WEB, path.lstrip("/")))
92+ if not fp.startswith(WEB) or not os.path.isfile(fp):
93+ return self._send(404, b"not found")
94+ with open(fp, "rb") as fh:
95+ body = fh.read()
96+ return self._send(200, body, MIME.get(os.path.splitext(fp)[1],
97+ "application/octet-stream"))
98+ except BrokenPipeError:
99+ pass
100+
101+
102+def main():
103+ port = int(sys.argv[1]) if len(sys.argv) > 1 else 8787
104+ print(f"HFChart — http://localhost:{port} (données : https://www.hfmarketdata.io)")
105+ ThreadingHTTPServer(("0.0.0.0", port), Handler).serve_forever()
106+
107+
108+if __name__ == "__main__":
109+ main()
added web/css/style.css +402 −0
@@ -0,0 +1,402 @@
1+/* ============================================================================
2+ * HFChart — styles & tokens (clair par défaut, sombre sélectionné, pas dérivé)
3+ * Author : Simon-Pierre Boucher — contact@spboucher.ai
4+ * ----------------------------------------------------------------------------
5+ * Les couleurs de séries suivent la palette catégorielle validée (ordre FIXE).
6+ * Hausse/baisse : schéma "classic" (vert/rouge, avec encodage secondaire par
7+ * la géométrie des chandelles) ou "cvd" (bleu/rouge, validé CVD ΔE 19,2).
8+ * ==========================================================================*/
9+
10+:root {
11+ color-scheme: light;
12+ --page: #f4f4f2;
13+ --surface: #fcfcfb;
14+ --ink: #0b0b0b;
15+ --ink2: #52514e;
16+ --muted: #898781;
17+ --grid: #e1e0d9;
18+ --axis: #c3c2b7;
19+ --border: rgba(11, 11, 11, 0.10);
20+ --chip: #0b0b0b;
21+ --chip-text: #ffffff;
22+ --series-1: #2a78d6;
23+ --series-2: #eb6834;
24+ --series-3: #1baf7a;
25+ --series-4: #eda100;
26+ --series-5: #e87ba4;
27+ --series-6: #008300;
28+ --series-7: #4a3aa7;
29+ --series-8: #e34948;
30+ --up: #0ca30c;
31+ --dn: #d03b3b;
32+ --accent-ui: #2a78d6;
33+}
34+
35+:root[data-theme="dark"] {
36+ color-scheme: dark;
37+ --page: #0d0d0d;
38+ --surface: #1a1a19;
39+ --ink: #ffffff;
40+ --ink2: #c3c2b7;
41+ --muted: #898781;
42+ --grid: #2c2c2a;
43+ --axis: #383835;
44+ --border: rgba(255, 255, 255, 0.10);
45+ --chip: #ffffff;
46+ --chip-text: #0b0b0b;
47+ --series-1: #3987e5;
48+ --series-2: #d95926;
49+ --series-3: #199e70;
50+ --series-4: #c98500;
51+ --series-5: #d55181;
52+ --series-6: #008300;
53+ --series-7: #9085e9;
54+ --series-8: #e66767;
55+ --up: #0ca30c;
56+ --dn: #d03b3b;
57+ --accent-ui: #3987e5;
58+}
59+
60+/* Schéma hausse/baisse CVD-sûr (paire divergente bleu↔rouge validée). */
61+:root[data-scheme="cvd"] { --up: #2a78d6; --dn: #e34948; }
62+:root[data-theme="dark"][data-scheme="cvd"] { --up: #3987e5; --dn: #e66767; }
63+
64+* { box-sizing: border-box; }
65+
66+html, body {
67+ margin: 0;
68+ height: 100%;
69+ font: 13px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif;
70+ background: var(--page);
71+ color: var(--ink);
72+}
73+
74+body { display: flex; flex-direction: column; }
75+
76+a { color: var(--accent-ui); text-decoration: none; }
77+a:hover { text-decoration: underline; }
78+.dim { color: var(--muted); }
79+
80+/* ------------------------------------------------------------------ topbar */
81+#topbar {
82+ display: flex;
83+ align-items: center;
84+ gap: 8px;
85+ padding: 8px 12px;
86+ background: var(--surface);
87+ border-bottom: 1px solid var(--border);
88+ flex-wrap: wrap;
89+}
90+
91+.brand { display: flex; align-items: center; gap: 8px; margin-right: 8px; }
92+.brand .logo {
93+ background: var(--accent-ui);
94+ color: #fff;
95+ font-weight: 700;
96+ font-size: 11px;
97+ padding: 3px 6px;
98+ border-radius: 6px;
99+ letter-spacing: 0.5px;
100+}
101+.brand strong { font-size: 15px; }
102+.brand .provider {
103+ font-size: 11px;
104+ color: var(--muted);
105+ border: 1px solid var(--border);
106+ border-radius: 999px;
107+ padding: 2px 9px;
108+}
109+.brand .provider:hover { color: var(--accent-ui); text-decoration: none; }
110+
111+button, select, input[type="search"], input[type="number"] {
112+ font: inherit;
113+ color: var(--ink);
114+ background: var(--surface);
115+ border: 1px solid var(--border);
116+ border-radius: 7px;
117+ padding: 5px 10px;
118+ cursor: pointer;
119+}
120+select { cursor: pointer; }
121+button:hover:not(:disabled), select:hover { border-color: var(--accent-ui); }
122+button:disabled { opacity: 0.35; cursor: not-allowed; }
123+button.active { background: var(--accent-ui); color: #fff; border-color: var(--accent-ui); }
124+
125+.seg { display: flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
126+.seg button { border: none; border-radius: 0; padding: 5px 9px; }
127+.seg button + button { border-left: 1px solid var(--border); }
128+
129+.dropdown { position: relative; }
130+.dropdown .menu {
131+ display: none;
132+ position: absolute;
133+ top: calc(100% + 4px);
134+ left: 0;
135+ z-index: 40;
136+ min-width: 240px;
137+ background: var(--surface);
138+ border: 1px solid var(--border);
139+ border-radius: 10px;
140+ box-shadow: 0 10px 32px rgba(0, 0, 0, 0.18);
141+ padding: 8px;
142+ max-height: 60vh;
143+ overflow: auto;
144+}
145+.dropdown.open .menu { display: block; }
146+.dropdown .menu label { display: flex; align-items: center; gap: 8px; padding: 5px 6px; border-radius: 6px; cursor: pointer; }
147+.dropdown .menu label:hover { background: var(--page); }
148+.dropdown .menu em { color: var(--muted); font-style: normal; font-size: 11px; margin-left: auto; }
149+.param-row { justify-content: space-between; }
150+.param-row input { width: 90px; cursor: text; }
151+
152+/* ------------------------------------------------------------------ layout */
153+#layout { display: flex; flex: 1; min-height: 0; }
154+
155+#sidebar {
156+ width: 218px;
157+ flex: none;
158+ display: flex;
159+ flex-direction: column;
160+ gap: 8px;
161+ padding: 10px;
162+ background: var(--surface);
163+ border-right: 1px solid var(--border);
164+ min-height: 0;
165+}
166+#group-tabs { display: flex; flex-wrap: wrap; gap: 4px; }
167+#group-tabs button { padding: 3px 8px; font-size: 12px; border-radius: 999px; }
168+#search { cursor: text; }
169+#symbols {
170+ list-style: none;
171+ margin: 0;
172+ padding: 0;
173+ overflow-y: auto;
174+ flex: 1;
175+ min-height: 0;
176+ border-top: 1px solid var(--border);
177+}
178+#symbols li {
179+ padding: 4px 8px;
180+ border-radius: 6px;
181+ cursor: pointer;
182+ font-variant-numeric: tabular-nums;
183+}
184+#symbols li:hover { background: var(--page); }
185+#symbols li.active { background: var(--accent-ui); color: #fff; }
186+#symbols li.compared { outline: 2px solid var(--accent-ui); outline-offset: -2px; }
187+#sym-count { margin: 0; font-size: 11px; }
188+.hint {
189+ margin: 0;
190+ font-size: 11.5px;
191+ color: var(--ink2);
192+ background: var(--page);
193+ border: 1px solid var(--border);
194+ border-radius: 7px;
195+ padding: 6px 8px;
196+ display: none;
197+}
198+
199+#chart-area { flex: 1; position: relative; min-width: 0; display: flex; flex-direction: column; }
200+#chart-wrap { position: relative; flex: 1; min-height: 0; background: var(--surface); }
201+#chart-wrap.loading::after {
202+ content: "";
203+ position: absolute;
204+ top: 10px; right: 84px;
205+ width: 14px; height: 14px;
206+ border: 2px solid var(--grid);
207+ border-top-color: var(--accent-ui);
208+ border-radius: 50%;
209+ animation: spin 0.8s linear infinite;
210+ z-index: 5;
211+}
212+@keyframes spin { to { transform: rotate(360deg); } }
213+
214+#legend {
215+ position: absolute;
216+ top: 8px; left: 10px;
217+ z-index: 4;
218+ display: flex;
219+ align-items: center;
220+ gap: 8px;
221+ flex-wrap: wrap;
222+ pointer-events: none;
223+ font-size: 12.5px;
224+ background: color-mix(in srgb, var(--surface) 74%, transparent);
225+ backdrop-filter: blur(8px) saturate(1.2);
226+ -webkit-backdrop-filter: blur(8px) saturate(1.2);
227+ border: 1px solid var(--border);
228+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.07);
229+ border-radius: 9px;
230+ padding: 4px 10px;
231+ max-width: calc(100% - 110px);
232+}
233+#legend .sym { font-weight: 700; font-size: 14px; }
234+#legend .k { color: var(--muted); margin-right: 1px; }
235+#legend b.up { color: var(--up); font-weight: 600; }
236+#legend b.dn { color: var(--dn); font-weight: 600; }
237+#legend #ohlcv { font-variant-numeric: tabular-nums; }
238+.chip-sym { display: inline-flex; align-items: center; gap: 5px; font-weight: 600; }
239+.chip-sym i { width: 10px; height: 10px; border-radius: 3px; display: inline-block; }
240+
241+#footer {
242+ padding: 5px 12px;
243+ font-size: 11px;
244+ color: var(--muted);
245+ background: var(--surface);
246+ border-top: 1px solid var(--border);
247+}
248+
249+/* ------------------------------------------------------------------ panels */
250+.overlay-panel {
251+ display: none;
252+ position: fixed;
253+ inset: 0;
254+ z-index: 60;
255+ background: rgba(0, 0, 0, 0.42);
256+ padding: 4vh 4vw;
257+}
258+.overlay-panel.open { display: flex; }
259+.overlay-panel .panel {
260+ margin: auto;
261+ width: min(1100px, 100%);
262+ max-height: 92vh;
263+ overflow: auto;
264+ background: var(--page);
265+ border: 1px solid var(--border);
266+ border-radius: 14px;
267+ padding: 16px 20px;
268+}
269+.panel-head { display: flex; align-items: center; justify-content: space-between; }
270+.panel-head h2 { margin: 0; font-size: 17px; }
271+
272+#gallery-grid {
273+ display: grid;
274+ grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
275+ gap: 12px;
276+ margin-top: 12px;
277+}
278+.g-card {
279+ display: flex;
280+ flex-direction: column;
281+ gap: 4px;
282+ text-align: left;
283+ padding: 10px;
284+ border-radius: 10px;
285+ background: var(--surface);
286+}
287+.g-card canvas { width: 100%; height: 96px; display: block; }
288+.g-card strong { font-size: 13px; }
289+.g-card span { font-size: 11px; color: var(--muted); }
290+.g-card.active {
291+ /* neutralise le style bouton .active (fond bleu) : la sélection se marque
292+ par un anneau, le mini-chart garde ses couleurs lisibles */
293+ background: var(--surface);
294+ color: inherit;
295+ border-color: var(--border);
296+ outline: 2px solid var(--accent-ui);
297+ outline-offset: 1px;
298+}
299+
300+.table-scroll { overflow: auto; max-height: 74vh; margin-top: 10px; }
301+#table-view table { border-collapse: collapse; width: 100%; font-variant-numeric: tabular-nums; }
302+#table-view th, #table-view td { padding: 4px 10px; text-align: right; border-bottom: 1px solid var(--border); }
303+#table-view th:first-child, #table-view td:first-child { text-align: left; }
304+#table-view th { position: sticky; top: 0; background: var(--page); color: var(--ink2); }
305+
306+/* ------------------------------------------------------------------ toast */
307+.toast {
308+ position: fixed;
309+ bottom: 40px;
310+ left: 50%;
311+ transform: translate(-50%, 20px);
312+ background: var(--chip);
313+ color: var(--chip-text);
314+ padding: 8px 16px;
315+ border-radius: 9px;
316+ opacity: 0;
317+ pointer-events: none;
318+ transition: opacity 0.2s, transform 0.2s;
319+ z-index: 90;
320+ max-width: 80vw;
321+}
322+.toast.show { opacity: 1; transform: translate(-50%, 0); }
323+.toast.err { background: #d03b3b; color: #fff; }
324+
325+/* ------------------------------------------------------------------ export */
326+#export-code {
327+ width: 100%;
328+ height: 260px;
329+ margin-top: 10px;
330+ font: 11.5px/1.5 ui-monospace, "SF Mono", Menlo, monospace;
331+ color: var(--ink);
332+ background: var(--surface);
333+ border: 1px solid var(--border);
334+ border-radius: 8px;
335+ padding: 10px;
336+ resize: vertical;
337+ white-space: pre;
338+ cursor: text;
339+}
340+.export-actions { display: flex; align-items: center; gap: 12px; margin-top: 10px; }
341+button.primary { background: var(--accent-ui); color: #fff; border-color: var(--accent-ui); }
342+#export-tabs { margin-top: 10px; }
343+#export-note { margin: 10px 0 0; }
344+
345+/* ------------------------------------------------------------------ embed (iframe ?embed=1) */
346+body.embed #topbar, body.embed #sidebar, body.embed #footer { display: none; }
347+body.embed #layout { height: 100vh; }
348+
349+/* ------------------------------------------------------------------ mobile */
350+#btn-symbols { display: none; }
351+
352+@media (max-width: 900px) {
353+ .brand .provider { display: none; }
354+}
355+
356+@media (max-width: 760px) {
357+ #topbar {
358+ flex-wrap: nowrap;
359+ overflow-x: auto;
360+ -webkit-overflow-scrolling: touch;
361+ gap: 6px;
362+ padding: 6px 8px;
363+ scrollbar-width: none;
364+ }
365+ #topbar::-webkit-scrollbar { display: none; }
366+ #topbar button, #topbar select { padding: 6px 9px; flex: none; }
367+ .brand { margin-right: 2px; }
368+ .brand strong { display: none; }
369+ .seg { flex: none; }
370+
371+ #btn-symbols { display: inline-block; font-size: 16px; padding: 5px 10px; }
372+ #sidebar {
373+ position: fixed;
374+ top: 0; bottom: 0; left: 0;
375+ width: min(300px, 84vw);
376+ z-index: 55;
377+ transform: translateX(-105%);
378+ transition: transform 0.22s ease;
379+ box-shadow: 8px 0 32px rgba(0, 0, 0, 0.25);
380+ border-right: 1px solid var(--border);
381+ }
382+ body.drawer-open #sidebar { transform: none; }
383+ #drawer-backdrop {
384+ display: none;
385+ position: fixed;
386+ inset: 0;
387+ z-index: 54;
388+ background: rgba(0, 0, 0, 0.35);
389+ }
390+ body.drawer-open #drawer-backdrop { display: block; }
391+ #symbols li { padding: 9px 10px; } /* cibles tactiles ≥ 36px */
392+ #group-tabs button { padding: 6px 11px; }
393+
394+ #legend { font-size: 11.5px; gap: 6px; max-width: calc(100% - 70px); }
395+ #legend .sym { font-size: 13px; }
396+ .overlay-panel { padding: 2vh 2vw; }
397+ .overlay-panel .panel { padding: 12px 14px; max-height: 96vh; }
398+ #gallery-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; }
399+ .g-card canvas { height: 74px; }
400+ #footer { display: none; }
401+ #export-code { height: 200px; font-size: 10.5px; }
402+}
added web/index.html +118 −0
@@ -0,0 +1,118 @@
1+<!DOCTYPE html>
2+<!-- ==========================================================================
3+ HFChart — la référence des charts haute fréquence, from scratch (zéro dépendance)
4+ Données : API HF Market Data — https://www.hfmarketdata.io
5+ Author : Simon-Pierre Boucher — contact@spboucher.ai
6+=========================================================================== -->
7+<html lang="fr" data-theme="light" data-scheme="classic">
8+<head>
9+ <meta charset="utf-8">
10+ <meta name="viewport" content="width=device-width, initial-scale=1">
11+ <meta name="author" content="Simon-Pierre Boucher — contact@spboucher.ai">
12+ <meta name="description" content="HFChart — catalogue de référence des charts haute fréquence : chandelles, Heikin-Ashi, Renko, Kagi, Point & Figure… rendus canvas from scratch, données HF Market Data.">
13+ <title>HFChart — charts haute fréquence · HF Market Data</title>
14+ <link rel="stylesheet" href="css/style.css">
15+</head>
16+<body>
17+ <header id="topbar">
18+ <button id="btn-symbols" type="button" aria-label="Symboles" title="Symboles">☰</button>
19+ <div class="brand">
20+ <span class="logo">HF</span>
21+ <strong>HFChart</strong>
22+ <a class="provider" href="https://www.hfmarketdata.io" target="_blank" rel="noopener">
23+ données · hfmarketdata.io
24+ </a>
25+ </div>
26+ <div id="tf-row" class="seg" role="group" aria-label="Timeframe"></div>
27+ <select id="type-select" aria-label="Type de chart"></select>
28+ <div class="dropdown">
29+ <button type="button">Indicateurs ▾</button>
30+ <div class="menu" id="ind-menu"></div>
31+ </div>
32+ <div class="dropdown">
33+ <button type="button">Réglages ▾</button>
34+ <div class="menu" id="params"></div>
35+ </div>
36+ <button id="btn-gallery" type="button" title="Catalogue des types de charts">Galerie</button>
37+ <button id="btn-table" type="button" title="Vue table des barres visibles">Données</button>
38+ <button id="btn-export" type="button" title="Copier un snippet du graphe avec ses données intégrées">Exporter</button>
39+ <button id="btn-log" type="button" title="Échelle logarithmique">log</button>
40+ <select id="scheme-select" aria-label="Couleurs hausse/baisse" title="Couleurs hausse/baisse">
41+ <option value="classic">Vert / rouge</option>
42+ <option value="cvd">Bleu / rouge (daltonisme)</option>
43+ </select>
44+ <button id="btn-theme" type="button" title="Thème clair / sombre">☾</button>
45+ </header>
46+
47+ <div id="layout">
48+ <div id="drawer-backdrop"></div>
49+ <aside id="sidebar">
50+ <nav id="group-tabs" aria-label="Classes d’actifs"></nav>
51+ <input id="search" type="search" placeholder="Rechercher un symbole…" autocomplete="off">
52+ <p id="compare-hint" class="hint">Mode comparaison : cliquez des symboles pour les ajouter / retirer (max 4 séries).</p>
53+ <ul id="symbols"></ul>
54+ <p id="sym-count" class="dim"></p>
55+ </aside>
56+
57+ <main id="chart-area">
58+ <div id="legend" aria-live="polite"></div>
59+ <div id="chart-wrap"></div>
60+ </main>
61+ </div>
62+
63+ <footer id="footer">
64+ HFChart — moteur de rendu canvas écrit from scratch, zéro dépendance ·
65+ Données <a href="https://www.hfmarketdata.io" target="_blank" rel="noopener">HF Market Data</a> ·
66+ Simon-Pierre Boucher — <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a>
67+ </footer>
68+
69+ <div id="gallery" class="overlay-panel" role="dialog" aria-label="Catalogue des types de charts">
70+ <div class="panel">
71+ <div class="panel-head">
72+ <h2>Catalogue des charts haute fréquence</h2>
73+ <button id="gallery-close" type="button" aria-label="Fermer">✕</button>
74+ </div>
75+ <p class="dim">Chaque type est rendu en direct sur le symbole courant (journalier). Cliquez pour l’ouvrir.</p>
76+ <div id="gallery-grid"></div>
77+ </div>
78+ </div>
79+
80+ <div id="export-view" class="overlay-panel" role="dialog" aria-label="Exporter le graphe">
81+ <div class="panel">
82+ <div class="panel-head">
83+ <h2>Exporter ce graphe</h2>
84+ <button id="export-close" type="button" aria-label="Fermer">✕</button>
85+ </div>
86+ <div class="seg" id="export-tabs">
87+ <button data-tab="html" class="active" type="button">HTML autonome (données intégrées)</button>
88+ <button data-tab="iframe" type="button">iframe live</button>
89+ </div>
90+ <p id="export-note" class="dim"></p>
91+ <textarea id="export-code" readonly spellcheck="false" wrap="off"></textarea>
92+ <div class="export-actions">
93+ <button id="export-copy" type="button" class="primary">Copier le snippet</button>
94+ <span id="export-copied" class="dim"></span>
95+ </div>
96+ </div>
97+ </div>
98+
99+ <div id="table-view" class="overlay-panel" role="dialog" aria-label="Vue table">
100+ <div class="panel">
101+ <div class="panel-head">
102+ <h2 id="table-title">Données</h2>
103+ <button id="table-close" type="button" aria-label="Fermer">✕</button>
104+ </div>
105+ <div class="table-scroll"><table></table></div>
106+ </div>
107+ </div>
108+
109+ <div id="toast" class="toast"></div>
110+
111+ <script src="js/transforms.js"></script>
112+ <script src="js/indicators.js"></script>
113+ <script src="js/renderers.js"></script>
114+ <script src="js/engine.js"></script>
115+ <script src="js/data.js"></script>
116+ <script src="js/app.js"></script>
117+</body>
118+</html>
added web/js/app.js +688 −0
@@ -0,0 +1,688 @@
1+/* ============================================================================
2+ * HFChart — application : catalogue de charts HF, sélection, personnalisation
3+ * Données : API HF Market Data (https://www.hfmarketdata.io)
4+ * Author : Simon-Pierre Boucher — contact@spboucher.ai
5+ * ==========================================================================*/
6+(function () {
7+ 'use strict';
8+ const HF = window.HF;
9+ const $ = s => document.querySelector(s);
10+ const $$ = s => Array.from(document.querySelectorAll(s));
11+
12+ /* ------------------------------------------------------------ catalogue */
13+ const CHART_TYPES = [
14+ { id: 'candles', label: 'Chandelles', desc: 'OHLC classique, corps plein', draw: 'candles', vis: 220 },
15+ { id: 'hollow', label: 'Chandelles creuses', desc: 'Hausse creuse / baisse pleine — lisible sans couleur', draw: 'hollow', vis: 220 },
16+ { id: 'ohlc', label: 'Barres OHLC', desc: 'Barres à ticks open/close', draw: 'ohlc', vis: 260 },
17+ { id: 'line', label: 'Ligne', desc: 'Close en trait 2px', draw: 'line', vis: 600 },
18+ { id: 'step', label: 'Escalier', desc: 'Close en marches', draw: 'step', vis: 400 },
19+ { id: 'area', label: 'Aire', desc: 'Ligne + dégradé vers la base', draw: 'area', vis: 600 },
20+ { id: 'baseline', label: 'Baseline', desc: 'Au-dessus / en-dessous de l’ancre', draw: 'baseline', vis: 600 },
21+ { id: 'heikin', label: 'Heikin-Ashi', desc: 'Chandelles lissées de tendance', draw: 'candles', transform: 'heikinAshi', vis: 220 },
22+ { id: 'renko', label: 'Renko', desc: 'Briques à taille fixe (auto-ATR)', draw: 'bricks', transform: 'renko', vis: 160 },
23+ { id: 'linebreak', label: 'Three-Line Break', desc: 'Nouvelle barre sur cassure des 3 dernières', draw: 'bricks', transform: 'lineBreak', vis: 160 },
24+ { id: 'rangebars', label: 'Range Bars', desc: 'Chaque barre couvre un range fixe', draw: 'candles', transform: 'rangeBars', vis: 200 },
25+ { id: 'kagi', label: 'Kagi', desc: 'Yang épais / yin fin, renversement fixe', draw: 'kagi', transform: 'kagi', vis: 140, noVol: true },
26+ { id: 'pnf', label: 'Point & Figure', desc: 'Colonnes de X et O', draw: 'pnf', transform: 'pnf', vis: 90, noVol: true },
27+ { id: 'compare', label: 'Comparaison %', desc: 'Jusqu’à 4 symboles, base commune → variation %', draw: 'line', compare: true, vis: 600, noVol: true },
28+ ];
29+
30+ const OVERLAYS = [
31+ { id: 'sma20', label: 'SMA 20', slot: 3, make: (bars, p) => ({ vals: HF.ind.sma(HF.ind.closes(bars), p.sma20 || 20) }) },
32+ { id: 'sma50', label: 'SMA 50', slot: 1, make: (bars, p) => ({ vals: HF.ind.sma(HF.ind.closes(bars), p.sma50 || 50) }) },
33+ { id: 'ema20', label: 'EMA 20', slot: 2, make: (bars, p) => ({ vals: HF.ind.ema(HF.ind.closes(bars), p.ema20 || 20) }) },
34+ { id: 'boll', label: 'Bollinger 20·2', slot: 6, make: (bars, p) => { const b = HF.ind.bollinger(bars, p.bollN || 20, p.bollK || 2); return { band: b }; } },
35+ { id: 'vwap', label: 'VWAP session ±2σ', slot: 4, needVol: true, intraday: true,
36+ make: bars => { const v = HF.ind.vwapSession(bars); return { band: { up: v.up2, lo: v.dn2, mid: v.v } }; } },
37+ ];
38+ const PANES = [
39+ { id: 'rsi', label: 'RSI', kind: 'rsi', make: (bars, p) => HF.ind.rsi(bars, p.rsiN || 14) },
40+ { id: 'macd', label: 'MACD', kind: 'macd', make: (bars, p) => HF.ind.macd(bars, p.macdF || 12, p.macdS || 26, p.macdSig || 9) },
41+ { id: 'atr', label: 'ATR', kind: 'atr', make: (bars, p) => HF.ind.atr(bars, p.atrN || 14) },
42+ ];
43+ const TFS = [
44+ { id: '1', label: '1m' }, { id: '5', label: '5m' }, { id: '15', label: '15m' },
45+ { id: '30', label: '30m' }, { id: '60', label: '1h' }, { id: '240', label: '4h' },
46+ { id: 'D', label: '1J' }, { id: 'W', label: '1S' },
47+ ];
48+ const TF_WANT = { 1: 5000, 5: 5000, 15: 5000, 30: 5000, 60: 5000, 240: 3000, D: 12000, W: 12000 };
49+ const DEFAULT_SYMBOL = { index: 'SPX', etf: 'SPY', stock: 'AAPL', futures: 'ES', crypto: 'BTC', fx: 'EURUSD' };
50+
51+ /* ------------------------------------------------------------ état */
52+ const state = Object.assign({
53+ group: 'index', symbol: 'SPX', tf: '60', type: 'candles',
54+ theme: 'light', scheme: 'classic', log: false,
55+ inds: [], compare: [], params: {},
56+ }, load(), urlState());
57+ state.params = state.params || {};
58+
59+ /* Surcharges via l'URL (partage de vues) : ?symbol=SPY&group=etf&tf=60&type=renko
60+ * &inds=rsi,vwap&compare=QQQ,IWM&theme=dark&scheme=cvd&log=1 */
61+ function urlState() {
62+ const q = new URLSearchParams(location.search);
63+ const out = {};
64+ for (const k of ['group', 'symbol', 'tf', 'type', 'theme', 'scheme']) {
65+ if (q.get(k)) out[k] = q.get(k);
66+ }
67+ if (q.get('inds') != null) out.inds = q.get('inds').split(',').filter(Boolean);
68+ if (q.get('compare') != null) out.compare = q.get('compare').split(',').filter(Boolean);
69+ if (q.get('log') != null) out.log = q.get('log') === '1';
70+ return out;
71+ }
72+
73+ function load() {
74+ try { return JSON.parse(localStorage.getItem('hfchart-state') || '{}'); } catch (e) { return {}; }
75+ }
76+ function save() {
77+ localStorage.setItem('hfchart-state', JSON.stringify({
78+ group: state.group, symbol: state.symbol, tf: state.tf, type: state.type,
79+ theme: state.theme, scheme: state.scheme, log: state.log,
80+ inds: state.inds, compare: state.compare, params: state.params,
81+ }));
82+ }
83+
84+ let chart = null;
85+ let raw = null; // {parsed, hasVolume, decimals, srcLimitReached}
86+ let compareRaw = {}; // symbole → réponse
87+ let tfAvail = {}; // tf UI → dispo pour le groupe courant
88+ let loading = false;
89+ let curWant = 0;
90+
91+ function toast(msg, isErr) {
92+ const el = $('#toast');
93+ el.textContent = msg;
94+ el.className = 'toast show' + (isErr ? ' err' : '');
95+ clearTimeout(toast._t);
96+ toast._t = setTimeout(() => { el.className = 'toast'; }, 4000);
97+ }
98+
99+ /* ------------------------------------------------------------ spec */
100+ function typeDef() { return CHART_TYPES.find(t => t.id === state.type) || CHART_TYPES[0]; }
101+
102+ function buildSpec(keepView) {
103+ if (!raw) return;
104+ const def = typeDef();
105+ const bars = raw.parsed;
106+ let items = bars;
107+ const p = state.params;
108+ if (def.transform) {
109+ const opts = {};
110+ if (def.id === 'renko' && p.brick > 0) opts.brick = p.brick;
111+ if (def.id === 'rangebars' && p.range > 0) opts.range = p.range;
112+ if (def.id === 'kagi' && p.reversal > 0) opts.reversal = p.reversal;
113+ if (def.id === 'pnf') { if (p.box > 0) opts.box = p.box; if (p.pnfRev > 0) opts.reversal = p.pnfRev; }
114+ if (def.id === 'linebreak' && p.lines > 0) opts.lines = p.lines;
115+ items = HF.transforms[def.transform](bars, opts);
116+ if (!items.length) { toast('Pas assez de données pour ce type sur ce timeframe', true); items = bars; }
117+ }
118+
119+ const spec = {
120+ items,
121+ draw: def.draw,
122+ decimals: raw.decimals,
123+ logScale: state.log && !def.compare,
124+ defaultVisible: def.vis,
125+ hasVolume: raw.hasVolume,
126+ showVolume: raw.hasVolume && !def.noVol && !def.compare,
127+ overlays: [],
128+ panes: [],
129+ anchor: def.id === 'baseline' && items.length ? items[0].c : null,
130+ volumeProfile: state.inds.includes('vprofile') && raw.hasVolume && !def.transform && !def.compare,
131+ meta: { symbol: state.symbol, tfLabel: (TFS.find(t => t.id === state.tf) || {}).label || state.tf },
132+ };
133+
134+ if (!def.compare) {
135+ for (const ov of OVERLAYS) {
136+ if (!state.inds.includes(ov.id)) continue;
137+ if (ov.needVol && !raw.hasVolume) continue;
138+ if (ov.intraday && (state.tf === 'D' || state.tf === 'W')) continue;
139+ const made = ov.make(items, p);
140+ spec.overlays.push({ ...made, color: schemeSeries()[ov.slot], label: ov.label });
141+ }
142+ for (const pn of PANES) {
143+ if (!state.inds.includes(pn.id)) continue;
144+ spec.panes.push({ id: pn.id, kind: pn.kind, label: pn.label, data: pn.make(items, p) });
145+ }
146+ } else {
147+ buildCompare(spec);
148+ }
149+ chart.setSeries(spec, keepView);
150+ if (spec._absMain) { // comparaison : base % = première barre visible
151+ rebaseCompare(spec, chart.visibleRange()[0]);
152+ chart.render();
153+ }
154+ renderLegendStatic(spec);
155+ }
156+
157+ function schemeSeries() { return chart ? chart.th.series : []; }
158+
159+ /* Comparaison : tout est ré-exprimé en % depuis le début de la fenêtre.
160+ * Slots catégoriels FIXES (jamais recyclés) : le symbole principal garde
161+ * le slot 1, chaque comparé garde le slot de sa position. */
162+ function buildCompare(spec) {
163+ const main = raw.parsed;
164+ if (!main.length) return;
165+ spec.decimals = 2;
166+ spec.percent = true;
167+ spec.draw = 'none';
168+ spec.overlays = [];
169+ spec.anchor = null;
170+ const colors = schemeSeries();
171+ // séries absolues alignées sur la timeline du symbole principal
172+ // (report de la dernière valeur connue dans les trous de séance)
173+ spec._absMain = main;
174+ spec._absComp = state.compare.slice(0, 3).map((sym, k) => {
175+ const cr = compareRaw[sym];
176+ if (!cr) return null;
177+ const map = new Map(cr.parsed.map(b => [b.t, b.c]));
178+ let lastC = null;
179+ const closes = main.map(b => {
180+ const c = map.get(b.t);
181+ if (c != null) lastC = c;
182+ return lastC;
183+ });
184+ return { label: sym, color: colors[k + 1], closes };
185+ }).filter(Boolean);
186+ spec.items = main.map(b => ({ t: b.t, o: 0, h: 0, l: 0, c: 0, v: b.v }));
187+ spec.compare = [{ label: state.symbol, color: colors[0], vals: new Array(main.length).fill(null) }];
188+ for (const s of spec._absComp) spec.compare.push({ label: s.label, color: s.color, vals: new Array(main.length).fill(null) });
189+ rebaseCompare(spec, 0);
190+ }
191+
192+ /* Rebasage % sur la première barre visible — recalculé au pan/zoom,
193+ * comme une vraie échelle "percent". */
194+ let lastRebaseI0 = -1;
195+ function rebaseCompare(spec, i0) {
196+ const main = spec._absMain;
197+ if (!main) return;
198+ i0 = Math.max(0, Math.min(main.length - 1, i0));
199+ const baseM = main[i0].c;
200+ for (let i = 0; i < main.length; i++) {
201+ const b = main[i], it = spec.items[i];
202+ it.o = ((b.o / baseM) - 1) * 100;
203+ it.h = ((b.h / baseM) - 1) * 100;
204+ it.l = ((b.l / baseM) - 1) * 100;
205+ it.c = ((b.c / baseM) - 1) * 100;
206+ spec.compare[0].vals[i] = it.c;
207+ }
208+ spec._absComp.forEach((s, k) => {
209+ let base = null;
210+ for (let i = i0; i < s.closes.length; i++) { if (s.closes[i] != null) { base = s.closes[i]; break; } }
211+ const vals = spec.compare[k + 1].vals;
212+ for (let i = 0; i < s.closes.length; i++) {
213+ vals[i] = (base && s.closes[i] != null) ? ((s.closes[i] / base) - 1) * 100 : null;
214+ }
215+ });
216+ lastRebaseI0 = i0;
217+ }
218+
219+ /* ------------------------------------------------------------ chargement */
220+ async function loadMain(keepView) {
221+ const def = typeDef();
222+ loading = true;
223+ $('#chart-wrap').classList.add('loading');
224+ try {
225+ tfAvail = HF.data.tfAvailability(state.group);
226+ if (!tfAvail[state.tf]) {
227+ state.tf = tfAvail['60'] ? '60' : 'D';
228+ syncToolbar();
229+ }
230+ curWant = curWant || TF_WANT[state.tf] || 5000;
231+ raw = await HF.data.bars(state.group, state.symbol, state.tf, curWant);
232+ if (def.compare) {
233+ await Promise.all(state.compare.slice(0, 3).map(async s => {
234+ compareRaw[s] = await HF.data.bars(state.group, s, state.tf, curWant);
235+ }));
236+ }
237+ buildSpec(keepView);
238+ } catch (e) {
239+ toast('Erreur : ' + e.message, true);
240+ } finally {
241+ loading = false;
242+ $('#chart-wrap').classList.remove('loading');
243+ syncTfButtons();
244+ }
245+ }
246+
247+ function reload(resetLimit) {
248+ if (resetLimit) curWant = 0;
249+ loadMain(false);
250+ save();
251+ syncToolbar();
252+ }
253+
254+ /* pan/zoom : rebase la comparaison sur la 1re barre visible,
255+ * puis étend l'historique quand on bute sur le bord gauche */
256+ function maybeExtend(view) {
257+ if (chart.spec && chart.spec._absMain) {
258+ const i0 = Math.max(0, Math.floor(view.first));
259+ if (i0 !== lastRebaseI0) {
260+ rebaseCompare(chart.spec, i0);
261+ chart.render();
262+ }
263+ }
264+ if (loading || !raw) return;
265+ if (view.first > 40) return;
266+ if (raw.srcLimitReached) return;
267+ if (raw.parsed.length < curWant * 0.9) return; // le lac n'a pas plus d'historique
268+ curWant = Math.min(HF.data.MAX_API_LIMIT, curWant * 2);
269+ loadMain(true);
270+ }
271+
272+ /* ------------------------------------------------------------ légende OHLCV */
273+ function renderLegendStatic(spec) {
274+ const el = $('#legend');
275+ const def = typeDef();
276+ const tfLabel = (TFS.find(t => t.id === state.tf) || {}).label || state.tf;
277+ let html = `<span class="sym">${state.symbol}</span><span class="dim">· ${tfLabel} · ${def.label}</span><span id="ohlcv"></span>`;
278+ if (def.compare) {
279+ const colors = schemeSeries();
280+ const syms = [state.symbol, ...state.compare.slice(0, 3)];
281+ html = `<span class="dim">Comparaison % · ${tfLabel}</span>` + syms.map((s, k) =>
282+ `<span class="chip-sym"><i style="background:${colors[k]}"></i>${s}</span>`).join('');
283+ }
284+ el.innerHTML = html;
285+ updateOHLCV(null, null);
286+ }
287+
288+ function updateOHLCV(i, item) {
289+ const el = $('#ohlcv');
290+ if (!el || !raw || !chart.spec) return;
291+ const spec = chart.spec;
292+ const it = item || spec.items[spec.items.length - 1];
293+ if (!it) { el.textContent = ''; return; }
294+ const idx = i != null ? i : spec.items.length - 1;
295+ const prev = spec.items[idx - 1];
296+ const ref = prev ? prev.c : it.o;
297+ const chg = ref ? ((it.c / ref) - 1) * 100 : 0;
298+ const cls = it.c >= ref ? 'up' : 'dn';
299+ const f = v => HF.fmt.price(v, spec.decimals);
300+ el.innerHTML =
301+ `<span class="k">O</span>${f(it.o)} <span class="k">H</span>${f(it.h)} ` +
302+ `<span class="k">L</span>${f(it.l)} <span class="k">C</span><b class="${cls}">${f(it.c)}</b> ` +
303+ `<b class="${cls}">${chg >= 0 ? '+' : ''}${chg.toFixed(2)} %</b>` +
304+ (spec.hasVolume && it.v ? ` <span class="k">Vol</span>${HF.fmt.vol(it.v)}` : '');
305+ }
306+
307+ /* ------------------------------------------------------------ sidebar */
308+ function renderGroupTabs() {
309+ const tabs = $('#group-tabs');
310+ tabs.innerHTML = '';
311+ for (const g of HF.data.GROUPS) {
312+ const b = document.createElement('button');
313+ b.textContent = g.label;
314+ b.className = g.key === state.group ? 'active' : '';
315+ b.onclick = async () => {
316+ state.group = g.key;
317+ state.compare = [];
318+ state.symbol = DEFAULT_SYMBOL[g.key] || '';
319+ renderGroupTabs();
320+ await renderSymbolList(true);
321+ reload(true);
322+ };
323+ tabs.appendChild(b);
324+ }
325+ }
326+
327+ async function renderSymbolList(validate) {
328+ const ul = $('#symbols');
329+ ul.innerHTML = '<li class="dim">Chargement…</li>';
330+ let list;
331+ try {
332+ list = await HF.data.tickers(state.group);
333+ } catch (e) {
334+ ul.innerHTML = '';
335+ toast('Tickers : ' + e.message, true);
336+ return;
337+ }
338+ if (validate && !list.includes(state.symbol)) state.symbol = list[0] || '';
339+ const q = ($('#search').value || '').toUpperCase();
340+ ul.innerHTML = '';
341+ const frag = document.createDocumentFragment();
342+ let shown = 0;
343+ for (const s of list) {
344+ if (q && !s.toUpperCase().includes(q)) continue;
345+ if (++shown > 800) break; // le champ recherche affine au-delà
346+ const li = document.createElement('li');
347+ li.textContent = s;
348+ if (s === state.symbol) li.className = 'active';
349+ if (state.compare.includes(s)) li.className += ' compared';
350+ li.onclick = () => {
351+ if (compareMode() && s !== state.symbol) { toggleCompareSymbol(s); renderSymbolList(); return; }
352+ state.symbol = s;
353+ renderSymbolList();
354+ document.body.classList.remove('drawer-open');
355+ reload(true);
356+ };
357+ frag.appendChild(li);
358+ }
359+ ul.appendChild(frag);
360+ $('#sym-count').textContent = `${list.length} symboles`;
361+ }
362+
363+ function compareMode() { return typeDef().compare; }
364+
365+ function toggleCompareSymbol(sym) {
366+ const i = state.compare.indexOf(sym);
367+ if (i >= 0) state.compare.splice(i, 1);
368+ else {
369+ if (state.compare.length >= 3) { toast('Maximum 4 séries (lisibilité) — retirez-en une d’abord'); return; }
370+ state.compare.push(sym);
371+ }
372+ reload(false);
373+ }
374+
375+ /* ------------------------------------------------------------ toolbar */
376+ function syncTfButtons() {
377+ $$('#tf-row button').forEach(b => {
378+ b.classList.toggle('active', b.dataset.tf === state.tf);
379+ b.disabled = tfAvail[b.dataset.tf] === false;
380+ b.title = b.disabled ? 'Pas encore dans le lac de données' : '';
381+ });
382+ }
383+
384+ function syncToolbar() {
385+ syncTfButtons();
386+ $('#type-select').value = state.type;
387+ $('#btn-log').classList.toggle('active', state.log);
388+ $('#btn-theme').textContent = state.theme === 'dark' ? '☀︎' : '☾';
389+ $('#scheme-select').value = state.scheme;
390+ document.documentElement.dataset.theme = state.theme;
391+ document.documentElement.dataset.scheme = state.scheme;
392+ $$('#ind-menu input').forEach(cb => { cb.checked = state.inds.includes(cb.value); });
393+ renderParams();
394+ if (chart) { chart.readTheme(); chart.render(); }
395+ }
396+
397+ function buildToolbar() {
398+ const tfRow = $('#tf-row');
399+ for (const tf of TFS) {
400+ const b = document.createElement('button');
401+ b.textContent = tf.label;
402+ b.dataset.tf = tf.id;
403+ b.onclick = () => { if (!b.disabled) { state.tf = tf.id; reload(true); } };
404+ tfRow.appendChild(b);
405+ }
406+ const sel = $('#type-select');
407+ for (const t of CHART_TYPES) {
408+ const o = document.createElement('option');
409+ o.value = t.id; o.textContent = t.label;
410+ sel.appendChild(o);
411+ }
412+ sel.onchange = () => { state.type = sel.value; reload(false); updateCompareHint(); };
413+
414+ const menu = $('#ind-menu');
415+ const mk = (id, label, note) => {
416+ const l = document.createElement('label');
417+ l.innerHTML = `<input type="checkbox" value="${id}"> ${label}${note ? ` <em>${note}</em>` : ''}`;
418+ l.querySelector('input').onchange = e => {
419+ if (e.target.checked) state.inds.push(id);
420+ else state.inds = state.inds.filter(x => x !== id);
421+ reload(false);
422+ };
423+ menu.appendChild(l);
424+ };
425+ for (const ov of OVERLAYS) mk(ov.id, ov.label, ov.needVol ? 'volume requis' : '');
426+ mk('vprofile', 'Profil de volume', 'volume requis');
427+ for (const pn of PANES) mk(pn.id, pn.label + (pn.id === 'rsi' ? ' 14' : pn.id === 'atr' ? ' 14' : ' 12·26·9'), '');
428+
429+ $('#btn-log').onclick = () => { state.log = !state.log; buildSpec(true); save(); syncToolbar(); };
430+ $('#btn-theme').onclick = () => { state.theme = state.theme === 'dark' ? 'light' : 'dark'; save(); syncToolbar(); };
431+ $('#scheme-select').onchange = e => { state.scheme = e.target.value; save(); syncToolbar(); };
432+ $('#btn-gallery').onclick = openGallery;
433+ $('#btn-table').onclick = openTable;
434+ $('#btn-export').onclick = openExport;
435+ $('#search').oninput = () => renderSymbolList();
436+ // drawer mobile
437+ $('#btn-symbols').onclick = () => document.body.classList.toggle('drawer-open');
438+ $('#drawer-backdrop').onclick = () => document.body.classList.remove('drawer-open');
439+ document.addEventListener('click', e => {
440+ for (const dd of $$('.dropdown')) {
441+ if (!dd.contains(e.target)) dd.classList.remove('open');
442+ }
443+ });
444+ for (const dd of $$('.dropdown > button')) {
445+ dd.onclick = e => { e.stopPropagation(); dd.parentElement.classList.toggle('open'); };
446+ }
447+ for (const ov of $$('.overlay-panel')) {
448+ ov.addEventListener('click', e => { if (e.target === ov) ov.classList.remove('open'); });
449+ }
450+ }
451+
452+ function updateCompareHint() {
453+ $('#compare-hint').style.display = compareMode() ? 'block' : 'none';
454+ }
455+
456+ /* Réglages du type actif (brique, renversement, boîtes…) + périodes. */
457+ function renderParams() {
458+ const host = $('#params');
459+ const def = typeDef();
460+ host.innerHTML = '';
461+ const fields = [];
462+ if (def.id === 'renko') fields.push(['brick', 'Taille de brique (vide = auto ATR)']);
463+ if (def.id === 'rangebars') fields.push(['range', 'Range par barre (vide = auto)']);
464+ if (def.id === 'kagi') fields.push(['reversal', 'Renversement (vide = auto)']);
465+ if (def.id === 'pnf') { fields.push(['box', 'Taille de boîte (vide = auto)']); fields.push(['pnfRev', 'Renversement (boîtes, défaut 3)']); }
466+ if (def.id === 'linebreak') fields.push(['lines', 'Lignes à casser (défaut 3)']);
467+ if (state.inds.includes('sma20')) fields.push(['sma20', 'Période SMA 20']);
468+ if (state.inds.includes('sma50')) fields.push(['sma50', 'Période SMA 50']);
469+ if (state.inds.includes('ema20')) fields.push(['ema20', 'Période EMA 20']);
470+ if (state.inds.includes('boll')) { fields.push(['bollN', 'Bollinger : période']); fields.push(['bollK', 'Bollinger : écart-type']); }
471+ if (state.inds.includes('rsi')) fields.push(['rsiN', 'Période RSI']);
472+ if (state.inds.includes('atr')) fields.push(['atrN', 'Période ATR']);
473+ if (!fields.length) {
474+ host.innerHTML = '<p class="dim">Aucun réglage pour cette configuration.</p>';
475+ return;
476+ }
477+ for (const [key, label] of fields) {
478+ const row = document.createElement('label');
479+ row.className = 'param-row';
480+ row.innerHTML = `<span>${label}</span><input type="number" step="any" value="${state.params[key] ?? ''}">`;
481+ row.querySelector('input').onchange = e => {
482+ const v = parseFloat(e.target.value);
483+ if (isFinite(v) && v > 0) state.params[key] = v; else delete state.params[key];
484+ buildSpec(true);
485+ save();
486+ };
487+ host.appendChild(row);
488+ }
489+ }
490+
491+ /* ------------------------------------------------------------ galerie */
492+ async function openGallery() {
493+ const overlay = $('#gallery');
494+ overlay.classList.add('open');
495+ const grid = $('#gallery-grid');
496+ grid.innerHTML = '';
497+ let daily;
498+ try {
499+ daily = (await HF.data.bars(state.group, state.symbol, 'D', 400)).parsed.slice(-260);
500+ } catch (e) { toast('Erreur galerie : ' + e.message, true); return; }
501+ for (const t of CHART_TYPES) {
502+ const card = document.createElement('button');
503+ card.className = 'g-card' + (t.id === state.type ? ' active' : '');
504+ card.innerHTML = `<canvas></canvas><strong>${t.label}</strong><span>${t.desc}</span>`;
505+ card.onclick = () => { state.type = t.id; overlay.classList.remove('open'); reload(false); updateCompareHint(); };
506+ grid.appendChild(card);
507+ let items = daily;
508+ if (t.transform) items = HF.transforms[t.transform](daily, {});
509+ const draw = t.compare ? 'line' : t.draw;
510+ // les types à corps ont besoin de barres larges pour être reconnaissables
511+ const n = ['candles', 'hollow', 'ohlc', 'bricks'].includes(draw) ? 55 : 120;
512+ requestAnimationFrame(() => HF.mini(card.querySelector('canvas'), draw, items.slice(-n), chart.th));
513+ }
514+ $('#gallery-close').onclick = () => overlay.classList.remove('open');
515+ }
516+
517+ /* ------------------------------------------------------------ export snippet */
518+ const MAX_EXPORT_BARS = 3000;
519+
520+ /* Barres brutes couvrant la fenêtre visible (les transforms — Renko, Kagi… —
521+ * sont recalculées à l'identique dans l'embed à partir de ces barres). */
522+ function exportBars() {
523+ const def = typeDef();
524+ const [i0, i1] = chart.visibleRange();
525+ let bars;
526+ if (def.transform) {
527+ const t0 = chart.spec.items[i0] ? chart.spec.items[i0].t : raw.parsed[0].t;
528+ bars = raw.parsed.filter(b => b.t >= t0);
529+ } else {
530+ bars = raw.parsed.slice(i0, i1 + 1);
531+ }
532+ return bars.slice(-MAX_EXPORT_BARS);
533+ }
534+
535+ function buildEmbedSnippet() {
536+ const def = typeDef();
537+ const bars = exportBars();
538+ const d = raw.decimals;
539+ const r = v => +v.toFixed(d);
540+ const tfLabel = (TFS.find(t => t.id === state.tf) || {}).label || state.tf;
541+ const inds = state.inds.filter(x => x !== 'vprofile');
542+ const cfg = {
543+ symbol: state.symbol, tf: tfLabel, type: state.type, typeLabel: def.label,
544+ theme: state.theme, scheme: state.scheme,
545+ inds, params: state.params, decimals: d,
546+ hasVolume: raw.hasVolume && !def.noVol,
547+ visible: Math.min(bars.length, Math.round(chart.view.count)),
548+ bars: bars.map(b => [b.t, r(b.o), r(b.h), r(b.l), r(b.c), Math.round(b.v)]),
549+ };
550+ const id = 'hfchart-' + Math.random().toString(36).slice(2, 8);
551+ const base = location.origin.startsWith('http') ? location.origin : 'https://www.hfchart.io';
552+ return {
553+ count: bars.length,
554+ code:
555+`<!-- HFChart — ${state.symbol} · ${tfLabel} · ${def.label} — ${bars.length} barres intégrées
556+ Graphe interactif autonome (zoom, pan, crosshair), aucune requête réseau pour les données.
557+ https://www.hfchart.io — Simon-Pierre Boucher — contact@spboucher.ai -->
558+<div id="${id}" style="width:100%;height:420px"></div>
559+<script>
560+(window.HFChartEmbedQueue = window.HFChartEmbedQueue || []).push({
561+ el: "#${id}",
562+ config: ${JSON.stringify(cfg)}
563+});
564+</script>
565+<script async src="${base}/embed.js"></script>`,
566+ };
567+ }
568+
569+ function buildIframeSnippet() {
570+ const base = location.origin.startsWith('http') ? location.origin : 'https://www.hfchart.io';
571+ const q = new URLSearchParams({
572+ group: state.group, symbol: state.symbol, tf: state.tf, type: state.type,
573+ theme: state.theme, scheme: state.scheme, embed: '1',
574+ });
575+ if (state.inds.length) q.set('inds', state.inds.join(','));
576+ if (state.compare.length && compareMode()) q.set('compare', state.compare.join(','));
577+ if (state.log) q.set('log', '1');
578+ return {
579+ code:
580+`<!-- HFChart live — ${state.symbol} — https://www.hfchart.io -->
581+<iframe src="${base}/?${q.toString()}"
582+ style="width:100%;height:480px;border:0;border-radius:8px"
583+ loading="lazy" title="HFChart — ${state.symbol}"></iframe>`,
584+ };
585+ }
586+
587+ let exportTab = 'html';
588+ function renderExport() {
589+ const noteEl = $('#export-note');
590+ const codeEl = $('#export-code');
591+ $$('#export-tabs button').forEach(b => b.classList.toggle('active', b.dataset.tab === exportTab));
592+ if (exportTab === 'html') {
593+ if (compareMode()) {
594+ noteEl.textContent = 'Le mode Comparaison % s’exporte en iframe live (onglet suivant) — le snippet HTML autonome couvre les 13 autres types.';
595+ codeEl.value = '';
596+ return;
597+ }
598+ const s = buildEmbedSnippet();
599+ const kb = Math.round(s.code.length / 1024);
600+ noteEl.textContent =
601+ `Graphe 100 % autonome : ${s.count} barres visibles intégrées dans la page (~${kb} Ko), ` +
602+ `interactif (zoom, pan, crosshair), indicateurs et réglages inclus. Collez tel quel dans n’importe quelle page HTML.`;
603+ codeEl.value = s.code;
604+ } else {
605+ noteEl.textContent = 'Version live : le graphe embarqué charge la plateforme (données à jour via hfmarketdata.io).';
606+ codeEl.value = buildIframeSnippet().code;
607+ }
608+ }
609+
610+ function openExport() {
611+ if (!raw || !chart.spec) return;
612+ $('#export-view').classList.add('open');
613+ renderExport();
614+ $('#export-copied').textContent = '';
615+ $('#export-close').onclick = () => $('#export-view').classList.remove('open');
616+ $$('#export-tabs button').forEach(b => {
617+ b.onclick = () => { exportTab = b.dataset.tab; renderExport(); };
618+ });
619+ $('#export-copy').onclick = async () => {
620+ const code = $('#export-code').value;
621+ if (!code) return;
622+ try {
623+ await navigator.clipboard.writeText(code);
624+ } catch (e) {
625+ $('#export-code').select();
626+ document.execCommand('copy');
627+ }
628+ $('#export-copied').textContent = '✓ copié dans le presse-papiers';
629+ setTimeout(() => { $('#export-copied').textContent = ''; }, 2500);
630+ };
631+ }
632+
633+ /* ------------------------------------------------------------ vue table (accessibilité) */
634+ function openTable() {
635+ if (!chart || !chart.spec) return;
636+ const overlay = $('#table-view');
637+ overlay.classList.add('open');
638+ const [i0, i1] = chart.visibleRange();
639+ const items = chart.spec.items.slice(i0, i1 + 1).slice(-500);
640+ const f = v => HF.fmt.price(v, chart.spec.decimals);
641+ const hasV = chart.spec.hasVolume;
642+ let html = `<tr><th>Temps</th><th>Ouverture</th><th>Haut</th><th>Bas</th><th>Clôture</th>${hasV ? '<th>Volume</th>' : ''}</tr>`;
643+ for (let k = items.length - 1; k >= 0; k--) {
644+ const b = items[k];
645+ html += `<tr><td>${HF.fmt.time(b.t)}</td><td>${f(b.o)}</td><td>${f(b.h)}</td><td>${f(b.l)}</td><td>${f(b.c)}</td>${hasV ? `<td>${HF.fmt.vol(b.v)}</td>` : ''}</tr>`;
646+ }
647+ $('#table-view table').innerHTML = html;
648+ $('#table-title').textContent = `${state.symbol} — ${items.length} barres visibles (max 500)`;
649+ $('#table-close').onclick = () => overlay.classList.remove('open');
650+ }
651+
652+ /* ------------------------------------------------------------ boot */
653+ async function boot() {
654+ document.documentElement.dataset.theme = state.theme;
655+ document.documentElement.dataset.scheme = state.scheme;
656+ if (new URLSearchParams(location.search).get('embed') === '1') {
657+ document.body.classList.add('embed'); // mode iframe : chart seul
658+ }
659+ chart = new HF.HFChart($('#chart-wrap'), {
660+ onCrosshair: updateOHLCV,
661+ onViewChange: maybeExtend,
662+ });
663+ buildToolbar();
664+ syncToolbar();
665+ updateCompareHint();
666+ try {
667+ await HF.data.loadStatus();
668+ } catch (e) {
669+ toast('API hfmarketdata.io injoignable : ' + e.message, true);
670+ return;
671+ }
672+ renderGroupTabs();
673+ await renderSymbolList(true);
674+ reload(true);
675+ const panel = new URLSearchParams(location.search).get('panel');
676+ if (panel === 'gallery') openGallery();
677+ if (panel === 'table') setTimeout(openTable, 1500);
678+ if (new URLSearchParams(location.search).get('debug') === '1') {
679+ setTimeout(() => {
680+ $('#legend').textContent =
681+ `iw:${innerWidth} body:${document.body.clientWidth} wrap:${$('#chart-wrap').clientWidth}` +
682+ ` area:${$('#chart-area').clientWidth} layout:${$('#layout').clientWidth} chartW:${chart.w}|axis:${chart.axisW}`;
683+ }, 3000);
684+ }
685+ }
686+
687+ boot();
688+})();
added web/js/data.js +185 −0
@@ -0,0 +1,185 @@
1+/* ============================================================================
2+ * HFChart — couche données : API HF Market Data (https://www.hfmarketdata.io)
3+ * Author : Simon-Pierre Boucher — contact@spboucher.ai
4+ * ----------------------------------------------------------------------------
5+ * Le navigateur interroge l'API directement (CORS ouvert). Les timeframes
6+ * absents du lac sont dérivés côté client par resampling (1h→4h, 1J→1S,
7+ * 5min→15m, …). `/v1/status` pilote dynamiquement ce qui est disponible.
8+ * ==========================================================================*/
9+(function () {
10+ 'use strict';
11+ const HF = (window.HF = window.HF || {});
12+ const API = window.HFMD_API || 'https://www.hfmarketdata.io';
13+ const MAX_API_LIMIT = 50000;
14+
15+ const GROUPS = [
16+ { key: 'index', label: 'Indices' },
17+ { key: 'etf', label: 'ETF' },
18+ { key: 'stock', label: 'Actions' },
19+ { key: 'futures', label: 'Futures' },
20+ { key: 'crypto', label: 'Crypto' },
21+ { key: 'fx', label: 'FX' },
22+ ];
23+
24+ /* tf UI → sources candidates (ordre de préférence) + facteur de resampling */
25+ const TF_SOURCES = {
26+ '1': [['1min', 1]],
27+ '5': [['5min', 1], ['1min', 5]],
28+ '15': [['5min', 3], ['1min', 15]],
29+ '30': [['30min', 1], ['5min', 6], ['1min', 30]],
30+ '60': [['1hour', 1], ['30min', 2], ['5min', 12]],
31+ '240': [['1hour', 4], ['30min', 8]],
32+ 'D': [['1day', 1]],
33+ 'W': [['1day', 'W']],
34+ };
35+
36+ let status = null; // /v1/status → { asset: { tf: { adj: n } } }
37+
38+ async function getJSON(url) {
39+ const res = await fetch(url);
40+ if (!res.ok) {
41+ let msg = res.statusText;
42+ try { msg = (await res.json()).detail || msg; } catch (e) { /* texte brut */ }
43+ throw new Error(msg);
44+ }
45+ return res.json();
46+ }
47+
48+ async function loadStatus() {
49+ if (status) return status;
50+ status = (await getJSON(`${API}/v1/status`)).datasets || {};
51+ return status;
52+ }
53+
54+ function srcAvailable(asset, srcTf) {
55+ const a = status && status[asset];
56+ if (!a || !a[srcTf]) return false;
57+ return Object.values(a[srcTf]).some(n => n > 0);
58+ }
59+
60+ /* Résout un tf UI vers (source, facteur) selon la disponibilité réelle. */
61+ function resolveTf(asset, uiTf) {
62+ for (const [src, factor] of (TF_SOURCES[uiTf] || [])) {
63+ if (srcAvailable(asset, src)) return { src, factor };
64+ }
65+ return null;
66+ }
67+
68+ function tfAvailability(asset) {
69+ const out = {};
70+ for (const tf of Object.keys(TF_SOURCES)) out[tf] = !!resolveTf(asset, tf);
71+ return out;
72+ }
73+
74+ function parseDT(s) { // "YYYY-MM-DD HH:MM:SS" → epoch (UTC naïf)
75+ return Date.UTC(+s.slice(0, 4), +s.slice(5, 7) - 1, +s.slice(8, 10),
76+ +(s.slice(11, 13) || 0), +(s.slice(14, 16) || 0), +(s.slice(17, 19) || 0)) / 1000;
77+ }
78+
79+ function resampleSec(bars, sec) {
80+ const out = [];
81+ let cur = null, key = null;
82+ for (const b of bars) {
83+ const k = b.t - (b.t % sec);
84+ if (k !== key) {
85+ if (cur) out.push(cur);
86+ key = k;
87+ cur = { t: k, o: b.o, h: b.h, l: b.l, c: b.c, v: b.v };
88+ } else {
89+ cur.h = Math.max(cur.h, b.h);
90+ cur.l = Math.min(cur.l, b.l);
91+ cur.c = b.c;
92+ cur.v += b.v;
93+ }
94+ }
95+ if (cur) out.push(cur);
96+ return out;
97+ }
98+
99+ function resampleWeekly(daily) {
100+ const out = [];
101+ let cur = null, key = null;
102+ for (const b of daily) {
103+ const wd = (Math.floor(b.t / 86400) + 3) % 7; // lundi = 0
104+ const k = b.t - wd * 86400;
105+ if (k !== key) {
106+ if (cur) out.push(cur);
107+ key = k;
108+ cur = { t: k, o: b.o, h: b.h, l: b.l, c: b.c, v: b.v };
109+ } else {
110+ cur.h = Math.max(cur.h, b.h);
111+ cur.l = Math.min(cur.l, b.l);
112+ cur.c = b.c;
113+ cur.v += b.v;
114+ }
115+ }
116+ if (cur) out.push(cur);
117+ return out;
118+ }
119+
120+ function detectDecimals(bars) {
121+ let dec = 0;
122+ for (let i = Math.max(0, bars.length - 80); i < bars.length; i++) {
123+ const c = bars[i].c;
124+ for (let d = dec; d <= 6; d++) {
125+ if (Math.abs(c * Math.pow(10, d) - Math.round(c * Math.pow(10, d))) < 1e-6) {
126+ dec = Math.max(dec, d);
127+ break;
128+ }
129+ if (d === 6) dec = 6;
130+ }
131+ }
132+ return dec;
133+ }
134+
135+ const tickerCache = new Map();
136+ async function tickers(asset) {
137+ if (tickerCache.has(asset)) return tickerCache.get(asset);
138+ await loadStatus();
139+ // n'importe quel tf disponible fait l'affaire pour lister
140+ const tfs = Object.keys(status[asset] || {});
141+ const tf = tfs.includes('1day') ? '1day' : tfs[0];
142+ if (!tf) return [];
143+ const j = await getJSON(`${API}/v1/${asset}/tickers?timeframe=${tf}&limit=20000`);
144+ const list = (j.tickers || []).filter(t => t && t.trim());
145+ tickerCache.set(asset, list);
146+ return list;
147+ }
148+
149+ const barCache = new Map();
150+ /* Retourne {parsed:[{t,o,h,l,c,v}], hasVolume, decimals, srcLimitReached} */
151+ async function bars(asset, ticker, uiTf, wantBars) {
152+ await loadStatus();
153+ const r = resolveTf(asset, uiTf);
154+ if (!r) throw new Error(`Timeframe indisponible pour ${asset} (lac de données)`);
155+ const factor = r.factor === 'W' ? 5 : r.factor;
156+ const apiLimit = Math.min(MAX_API_LIMIT, Math.ceil(wantBars * factor * 1.1));
157+ const key = `${asset}|${ticker}|${r.src}|${apiLimit}`;
158+ if (barCache.has(key)) return barCache.get(key);
159+ const j = await getJSON(
160+ `${API}/v1/bars/${asset}/${encodeURIComponent(ticker)}?timeframe=${r.src}&order=desc&limit=${apiLimit}`);
161+ const rows = (j.data || []).reverse();
162+ let parsed = rows.map(rec => ({
163+ t: parseDT(rec.datetime), o: rec.open, h: rec.high, l: rec.low, c: rec.close,
164+ v: rec.volume != null ? rec.volume : 0,
165+ }));
166+ if (r.factor === 'W') parsed = resampleWeekly(parsed);
167+ else if (r.factor > 1) {
168+ const secOf = { '1min': 60, '5min': 300, '30min': 1800, '1hour': 3600 };
169+ parsed = resampleSec(parsed, secOf[r.src] * r.factor);
170+ }
171+ const out = {
172+ parsed,
173+ hasVolume: rows.some(rec => rec.volume != null && rec.volume > 0),
174+ decimals: detectDecimals(parsed),
175+ srcLimitReached: (j.count || rows.length) >= apiLimit && apiLimit >= MAX_API_LIMIT,
176+ src: r.src,
177+ };
178+ if (barCache.size > 24) barCache.delete(barCache.keys().next().value);
179+ barCache.set(key, out);
180+ return out;
181+ }
182+
183+ HF.data = { API, GROUPS, loadStatus, tfAvailability, resolveTf, tickers, bars,
184+ resampleSec, resampleWeekly, MAX_API_LIMIT };
185+})();
added web/js/embed-boot.js +151 −0
Large diff (151 lines) is not loaded by default to keep the page fast.Load diff
added web/js/engine.js +867 −0
Large diff (867 lines) is not loaded by default to keep the page fast.Load diff
added web/js/indicators.js +164 −0
Large diff (164 lines) is not loaded by default to keep the page fast.Load diff
added web/js/renderers.js +348 −0
Large diff (348 lines) is not loaded by default to keep the page fast.Load diff
added web/js/transforms.js +208 −0
Large diff (209 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme