feat: initial commit
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 | +[](https://www.hfchart.io) | |
| 6 | +[](https://www.hfmarketdata.io) | |
| 7 | +[](#architecture) | |
| 8 | +[](#le-catalogue) | |
| 9 | +[](#indicateurs) | |
| 10 | +[](#embed--snippet-autonome) | |
| 11 | +[](#interactions) | |
| 12 | +[](#architecture) | |
| 13 | +[](#couleurs) | |
| 14 | + | |
| 15 | +[](https://www.spboucher.ai) | |
| 16 | +[](mailto:contact@spboucher.ai) | |
| 17 | + | |
| 18 | + | |
| 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 | + | |
| 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 | + | |
| 69 | + | |
| 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 | + | |
| 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 | + | |
| 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 | + | |
| 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