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