feat: initial commit
Showing 19 changed files with +3,409 and −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
@@ -0,0 +1,151 @@ | ||
| 1 | +/* ============================================================================ | |
| 2 | + * HFChart Embed — bootstrapper des graphes intégrés (données inline) | |
| 3 | + * Author : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | + * ---------------------------------------------------------------------------- | |
| 5 | + * Servi concaténé après transforms/indicators/renderers/engine dans /embed.js. | |
| 6 | + * Usage (snippet généré par le bouton « Exporter » de www.hfchart.io) : | |
| 7 | + * <div id="hfchart-xxxx" style="width:100%;height:420px"></div> | |
| 8 | + * <script>(window.HFChartEmbedQueue=window.HFChartEmbedQueue||[]) | |
| 9 | + * .push({el:'#hfchart-xxxx',config:{symbol,tf,type,theme,scheme,inds, | |
| 10 | + * params,decimals,hasVolume,bars:[[t,o,h,l,c,v],…]}});</script> | |
| 11 | + * <script async src="https://www.hfchart.io/embed.js"></script> | |
| 12 | + * Le graphe est pleinement interactif (zoom, pan, crosshair) et 100 % autonome : | |
| 13 | + * les données sont dans la page, aucune requête réseau. | |
| 14 | + * ==========================================================================*/ | |
| 15 | +(function () { | |
| 16 | + 'use strict'; | |
| 17 | + if (window.__HFEmbedLoaded) return; | |
| 18 | + window.__HFEmbedLoaded = true; | |
| 19 | + const HF = window.HF; | |
| 20 | + | |
| 21 | + const TYPES = { | |
| 22 | + candles: { draw: 'candles' }, | |
| 23 | + hollow: { draw: 'hollow' }, | |
| 24 | + ohlc: { draw: 'ohlc' }, | |
| 25 | + line: { draw: 'line' }, | |
| 26 | + step: { draw: 'step' }, | |
| 27 | + area: { draw: 'area' }, | |
| 28 | + baseline: { draw: 'baseline' }, | |
| 29 | + heikin: { draw: 'candles', transform: 'heikinAshi' }, | |
| 30 | + renko: { draw: 'bricks', transform: 'renko' }, | |
| 31 | + linebreak: { draw: 'bricks', transform: 'lineBreak' }, | |
| 32 | + rangebars: { draw: 'candles', transform: 'rangeBars' }, | |
| 33 | + kagi: { draw: 'kagi', transform: 'kagi', noVol: true }, | |
| 34 | + pnf: { draw: 'pnf', transform: 'pnf', noVol: true }, | |
| 35 | + }; | |
| 36 | + | |
| 37 | + /* Tokens des deux thèmes (mêmes valeurs que style.css de la plateforme). */ | |
| 38 | + const TOKENS = { | |
| 39 | + light: { | |
| 40 | + '--surface': '#fcfcfb', '--ink': '#0b0b0b', '--ink2': '#52514e', | |
| 41 | + '--muted': '#898781', '--grid': '#e1e0d9', '--axis': '#c3c2b7', | |
| 42 | + '--chip': '#0b0b0b', '--chip-text': '#ffffff', | |
| 43 | + '--series-1': '#2a78d6', '--series-2': '#eb6834', '--series-3': '#1baf7a', | |
| 44 | + '--series-4': '#eda100', '--series-5': '#e87ba4', '--series-6': '#008300', | |
| 45 | + '--series-7': '#4a3aa7', '--series-8': '#e34948', | |
| 46 | + }, | |
| 47 | + dark: { | |
| 48 | + '--surface': '#1a1a19', '--ink': '#ffffff', '--ink2': '#c3c2b7', | |
| 49 | + '--muted': '#898781', '--grid': '#2c2c2a', '--axis': '#383835', | |
| 50 | + '--chip': '#ffffff', '--chip-text': '#0b0b0b', | |
| 51 | + '--series-1': '#3987e5', '--series-2': '#d95926', '--series-3': '#199e70', | |
| 52 | + '--series-4': '#c98500', '--series-5': '#d55181', '--series-6': '#008300', | |
| 53 | + '--series-7': '#9085e9', '--series-8': '#e66767', | |
| 54 | + }, | |
| 55 | + }; | |
| 56 | + const UPDN = { | |
| 57 | + classic: { '--up': '#0ca30c', '--dn': '#d03b3b' }, | |
| 58 | + cvd_light: { '--up': '#2a78d6', '--dn': '#e34948' }, | |
| 59 | + cvd_dark: { '--up': '#3987e5', '--dn': '#e66767' }, | |
| 60 | + }; | |
| 61 | + | |
| 62 | + const OVERLAYS = { | |
| 63 | + sma20: (bars, p) => ({ vals: HF.ind.sma(HF.ind.closes(bars), p.sma20 || 20), slot: 3 }), | |
| 64 | + sma50: (bars, p) => ({ vals: HF.ind.sma(HF.ind.closes(bars), p.sma50 || 50), slot: 1 }), | |
| 65 | + ema20: (bars, p) => ({ vals: HF.ind.ema(HF.ind.closes(bars), p.ema20 || 20), slot: 2 }), | |
| 66 | + boll: (bars, p) => ({ band: HF.ind.bollinger(bars, p.bollN || 20, p.bollK || 2), slot: 6 }), | |
| 67 | + vwap: bars => { const v = HF.ind.vwapSession(bars); return { band: { up: v.up2, lo: v.dn2, mid: v.v }, slot: 4, needVol: true }; }, | |
| 68 | + }; | |
| 69 | + const PANES = { | |
| 70 | + rsi: (bars, p) => ({ kind: 'rsi', data: HF.ind.rsi(bars, p.rsiN || 14) }), | |
| 71 | + macd: (bars, p) => ({ kind: 'macd', data: HF.ind.macd(bars, p.macdF || 12, p.macdS || 26, p.macdSig || 9) }), | |
| 72 | + atr: (bars, p) => ({ kind: 'atr', data: HF.ind.atr(bars, p.atrN || 14) }), | |
| 73 | + }; | |
| 74 | + | |
| 75 | + function render(item) { | |
| 76 | + const el = typeof item.el === 'string' ? document.querySelector(item.el) : item.el; | |
| 77 | + const cfg = item.config || {}; | |
| 78 | + if (!el || !cfg.bars || !cfg.bars.length) return; | |
| 79 | + const theme = cfg.theme === 'dark' ? 'dark' : 'light'; | |
| 80 | + const scheme = cfg.scheme === 'cvd' ? (theme === 'dark' ? 'cvd_dark' : 'cvd_light') : 'classic'; | |
| 81 | + el.innerHTML = ''; | |
| 82 | + el.style.position = el.style.position || 'relative'; | |
| 83 | + if (!el.style.height) el.style.height = '420px'; | |
| 84 | + for (const [k, v] of Object.entries(TOKENS[theme])) el.style.setProperty(k, v); | |
| 85 | + for (const [k, v] of Object.entries(UPDN[scheme])) el.style.setProperty(k, v); | |
| 86 | + el.style.background = TOKENS[theme]['--surface']; | |
| 87 | + el.style.borderRadius = el.style.borderRadius || '8px'; | |
| 88 | + el.style.overflow = 'hidden'; | |
| 89 | + el.style.fontFamily = 'system-ui, -apple-system, "Segoe UI", sans-serif'; | |
| 90 | + | |
| 91 | + const chartDiv = document.createElement('div'); | |
| 92 | + chartDiv.style.cssText = 'position:absolute;inset:0 0 20px 0;'; | |
| 93 | + el.appendChild(chartDiv); | |
| 94 | + | |
| 95 | + // barre de crédit (attribution + identité de la vue) | |
| 96 | + const credit = document.createElement('div'); | |
| 97 | + credit.style.cssText = | |
| 98 | + `position:absolute;left:0;right:0;bottom:0;height:20px;display:flex;align-items:center;` + | |
| 99 | + `justify-content:space-between;padding:0 8px;font-size:10.5px;box-sizing:border-box;` + | |
| 100 | + `color:${TOKENS[theme]['--muted']};border-top:1px solid ${TOKENS[theme]['--grid']};`; | |
| 101 | + const label = [cfg.symbol, cfg.tf, cfg.typeLabel || cfg.type].filter(Boolean).join(' · '); | |
| 102 | + credit.innerHTML = | |
| 103 | + `<span style="font-weight:600;color:${TOKENS[theme]['--ink2']}">${label}</span>` + | |
| 104 | + `<a href="https://www.hfchart.io" target="_blank" rel="noopener" ` + | |
| 105 | + `style="color:${TOKENS[theme]['--muted']};text-decoration:none">HFChart · données hfmarketdata.io</a>`; | |
| 106 | + el.appendChild(credit); | |
| 107 | + | |
| 108 | + const bars = cfg.bars.map(b => ({ t: b[0], o: b[1], h: b[2], l: b[3], c: b[4], v: b[5] || 0 })); | |
| 109 | + const def = TYPES[cfg.type] || TYPES.candles; | |
| 110 | + const p = cfg.params || {}; | |
| 111 | + let items = bars; | |
| 112 | + if (def.transform) { | |
| 113 | + const opts = {}; | |
| 114 | + if (cfg.type === 'renko' && p.brick > 0) opts.brick = p.brick; | |
| 115 | + if (cfg.type === 'rangebars' && p.range > 0) opts.range = p.range; | |
| 116 | + if (cfg.type === 'kagi' && p.reversal > 0) opts.reversal = p.reversal; | |
| 117 | + if (cfg.type === 'pnf') { if (p.box > 0) opts.box = p.box; if (p.pnfRev > 0) opts.reversal = p.pnfRev; } | |
| 118 | + if (cfg.type === 'linebreak' && p.lines > 0) opts.lines = p.lines; | |
| 119 | + items = HF.transforms[def.transform](bars, opts); | |
| 120 | + if (!items.length) items = bars; | |
| 121 | + } | |
| 122 | + | |
| 123 | + const chart = new HF.HFChart(chartDiv, {}); | |
| 124 | + const spec = { | |
| 125 | + items, | |
| 126 | + draw: def.draw, | |
| 127 | + decimals: cfg.decimals != null ? cfg.decimals : 2, | |
| 128 | + defaultVisible: Math.min(items.length, cfg.visible || 320), | |
| 129 | + hasVolume: !!cfg.hasVolume, | |
| 130 | + showVolume: !!cfg.hasVolume && !def.noVol, | |
| 131 | + overlays: [], | |
| 132 | + panes: [], | |
| 133 | + anchor: cfg.type === 'baseline' && items.length ? items[0].c : null, | |
| 134 | + }; | |
| 135 | + for (const id of (cfg.inds || [])) { | |
| 136 | + if (OVERLAYS[id]) { | |
| 137 | + const made = OVERLAYS[id](items, p); | |
| 138 | + if (made.needVol && !cfg.hasVolume) continue; | |
| 139 | + spec.overlays.push({ ...made, color: chart.th.series[made.slot] }); | |
| 140 | + } else if (PANES[id]) { | |
| 141 | + spec.panes.push({ id, label: id.toUpperCase(), ...PANES[id](items, p) }); | |
| 142 | + } | |
| 143 | + } | |
| 144 | + chart.setSeries(spec); | |
| 145 | + } | |
| 146 | + | |
| 147 | + const q = window.HFChartEmbedQueue = window.HFChartEmbedQueue || []; | |
| 148 | + const boot = () => { q.splice(0).forEach(render); q.push = it => { render(it); return 0; }; }; | |
| 149 | + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); | |
| 150 | + else boot(); | |
| 151 | +})(); | |
added
web/js/engine.js
+867 −0
@@ -0,0 +1,867 @@ | ||
| 1 | +/* ============================================================================ | |
| 2 | + * HFChart — moteur de chart canvas haute fréquence, from scratch (zéro dépendance) | |
| 3 | + * Author : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | + * ==========================================================================*/ | |
| 5 | +(function () { | |
| 6 | + 'use strict'; | |
| 7 | + const HF = (window.HF = window.HF || {}); | |
| 8 | + | |
| 9 | + const MONTHS = ['janv', 'févr', 'mars', 'avr', 'mai', 'juin', 'juil', 'août', 'sept', 'oct', 'nov', 'déc']; | |
| 10 | + const DAYS = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam']; | |
| 11 | + | |
| 12 | + function pad(n) { return n < 10 ? '0' + n : '' + n; } | |
| 13 | + | |
| 14 | + function fmtPrice(v, dec) { | |
| 15 | + if (v == null || !isFinite(v)) return '—'; | |
| 16 | + const d = dec != null ? dec : 2; | |
| 17 | + return v.toLocaleString('fr-CA', { minimumFractionDigits: d, maximumFractionDigits: d }); | |
| 18 | + } | |
| 19 | + | |
| 20 | + function fmtVol(v) { | |
| 21 | + if (v == null || !isFinite(v) || v === 0) return '0'; | |
| 22 | + const a = Math.abs(v); | |
| 23 | + if (a >= 1e9) return (v / 1e9).toFixed(2) + ' G'; | |
| 24 | + if (a >= 1e6) return (v / 1e6).toFixed(2) + ' M'; | |
| 25 | + if (a >= 1e3) return (v / 1e3).toFixed(1) + ' k'; | |
| 26 | + return String(Math.round(v)); | |
| 27 | + } | |
| 28 | + | |
| 29 | + function fmtTimeFull(t) { | |
| 30 | + const d = new Date(t * 1000); | |
| 31 | + return `${DAYS[d.getUTCDay()]} ${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()} · ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`; | |
| 32 | + } | |
| 33 | + | |
| 34 | + function hexToRgb(hex) { | |
| 35 | + const h = hex.replace('#', ''); | |
| 36 | + const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16); | |
| 37 | + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; | |
| 38 | + } | |
| 39 | + | |
| 40 | + function niceStep(raw) { | |
| 41 | + if (!(raw > 0)) return 1; | |
| 42 | + const p = Math.pow(10, Math.floor(Math.log10(raw))); | |
| 43 | + const m = raw / p; | |
| 44 | + return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 2.5 ? 2.5 : m <= 5 ? 5 : 10) * p; | |
| 45 | + } | |
| 46 | + | |
| 47 | + /* Échelle de temps candidate (secondes) pour l'axe X. */ | |
| 48 | + const TIME_LADDER = [60, 120, 300, 600, 900, 1800, 3600, 7200, 14400, 21600, 43200, | |
| 49 | + 86400, 2 * 86400, 7 * 86400, 14 * 86400, 'M', '3M', '6M', 'Y', '2Y', '5Y', '10Y']; | |
| 50 | + | |
| 51 | + class HFChart { | |
| 52 | + constructor(container, opts) { | |
| 53 | + this.el = container; | |
| 54 | + this.opts = opts || {}; | |
| 55 | + this.base = document.createElement('canvas'); | |
| 56 | + this.over = document.createElement('canvas'); | |
| 57 | + for (const c of [this.base, this.over]) { | |
| 58 | + c.style.position = 'absolute'; | |
| 59 | + c.style.inset = '0'; | |
| 60 | + container.appendChild(c); | |
| 61 | + } | |
| 62 | + this.over.style.cursor = 'crosshair'; | |
| 63 | + this.over.style.touchAction = 'none'; // pan/pinch gérés par le chart, pas par la page | |
| 64 | + this.spec = null; | |
| 65 | + this.view = { first: 0, count: 200 }; | |
| 66 | + this.cursor = null; // {x, y} en px CSS | |
| 67 | + this.axisW = 72; | |
| 68 | + this.axisH = 26; | |
| 69 | + this._bindEvents(); | |
| 70 | + this._ro = new ResizeObserver(() => this._resize()); | |
| 71 | + this._ro.observe(container); | |
| 72 | + this.readTheme(); | |
| 73 | + this._resize(); | |
| 74 | + } | |
| 75 | + | |
| 76 | + /* ----- thème : lit les custom properties CSS du container ----- */ | |
| 77 | + readTheme() { | |
| 78 | + const cs = getComputedStyle(this.el); | |
| 79 | + const g = name => (cs.getPropertyValue(name) || '').trim(); | |
| 80 | + const alpha = (hex, a) => { | |
| 81 | + const [r, gg, b] = hexToRgb(hex); | |
| 82 | + return `rgba(${r},${gg},${b},${a})`; | |
| 83 | + }; | |
| 84 | + const t = { | |
| 85 | + surface: g('--surface') || '#fcfcfb', | |
| 86 | + up: g('--up') || '#0ca30c', | |
| 87 | + dn: g('--dn') || '#d03b3b', | |
| 88 | + accent: g('--series-1') || '#2a78d6', | |
| 89 | + ink: g('--ink') || '#0b0b0b', | |
| 90 | + ink2: g('--ink2') || '#52514e', | |
| 91 | + muted: g('--muted') || '#898781', | |
| 92 | + grid: g('--grid') || '#e1e0d9', | |
| 93 | + baselineAxis: g('--axis') || '#c3c2b7', | |
| 94 | + chip: g('--chip') || '#0b0b0b', | |
| 95 | + chipText: g('--chip-text') || '#ffffff', | |
| 96 | + series: [1, 2, 3, 4, 5, 6, 7, 8].map(i => g('--series-' + i) || '#2a78d6'), | |
| 97 | + alpha, | |
| 98 | + }; | |
| 99 | + t.accentA25 = alpha(t.accent, 0.25); | |
| 100 | + t.accentA0 = alpha(t.accent, 0.0); | |
| 101 | + this.th = t; | |
| 102 | + } | |
| 103 | + | |
| 104 | + _resize() { | |
| 105 | + const r = this.el.getBoundingClientRect(); | |
| 106 | + const dpr = window.devicePixelRatio || 1; | |
| 107 | + this.w = Math.max(50, r.width); | |
| 108 | + this.h = Math.max(50, r.height); | |
| 109 | + // axisW recalculé à chaque render (largeur du dernier prix mesurée) | |
| 110 | + for (const c of [this.base, this.over]) { | |
| 111 | + c.width = Math.round(this.w * dpr); | |
| 112 | + c.height = Math.round(this.h * dpr); | |
| 113 | + c.style.width = this.w + 'px'; | |
| 114 | + c.style.height = this.h + 'px'; | |
| 115 | + c.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0); | |
| 116 | + } | |
| 117 | + this.render(); | |
| 118 | + } | |
| 119 | + | |
| 120 | + /* ----- données ----- */ | |
| 121 | + setSeries(spec, keepView) { | |
| 122 | + const prevN = this.spec ? this.spec.items.length : 0; | |
| 123 | + this.spec = spec; | |
| 124 | + const n = spec.items.length; | |
| 125 | + if (!keepView || !prevN) { | |
| 126 | + const def = Math.min(n, spec.defaultVisible || 220); | |
| 127 | + // marge de respiration à droite (~6 %), comme sur les plateformes pro | |
| 128 | + this.view = { first: n - def, count: Math.max(8, Math.round(def * 1.06)) }; | |
| 129 | + } else { | |
| 130 | + // conserve la fenêtre relative à la fin (nouvelles données à gauche) | |
| 131 | + const shift = n - prevN; | |
| 132 | + this.view.first += shift; | |
| 133 | + } | |
| 134 | + this._clampView(); | |
| 135 | + this.render(); | |
| 136 | + } | |
| 137 | + | |
| 138 | + _clampView() { | |
| 139 | + const n = this.spec ? this.spec.items.length : 0; | |
| 140 | + this.view.count = Math.max(8, Math.min(this.view.count, Math.max(16, n * 1.25))); | |
| 141 | + const minFirst = -this.view.count * 0.5; | |
| 142 | + const maxFirst = Math.max(minFirst, n - this.view.count * 0.15); | |
| 143 | + this.view.first = Math.max(minFirst, Math.min(this.view.first, maxFirst)); | |
| 144 | + } | |
| 145 | + | |
| 146 | + resetView() { | |
| 147 | + if (!this.spec) return; | |
| 148 | + const n = this.spec.items.length; | |
| 149 | + const def = Math.min(n, this.spec.defaultVisible || 220); | |
| 150 | + this.view = { first: n - def, count: Math.max(8, Math.round(def * 1.06)) }; | |
| 151 | + this.render(); | |
| 152 | + } | |
| 153 | + | |
| 154 | + visibleRange() { | |
| 155 | + const n = this.spec ? this.spec.items.length : 0; | |
| 156 | + const i0 = Math.max(0, Math.floor(this.view.first)); | |
| 157 | + const i1 = Math.min(n - 1, Math.ceil(this.view.first + this.view.count)); | |
| 158 | + return [i0, i1]; | |
| 159 | + } | |
| 160 | + | |
| 161 | + /* ----- layout des panes ----- */ | |
| 162 | + _layout() { | |
| 163 | + const spec = this.spec; | |
| 164 | + const plotW = this.w - this.axisW; | |
| 165 | + const plotH = this.h - this.axisH; | |
| 166 | + const panes = []; | |
| 167 | + const subs = (spec.panes || []); | |
| 168 | + const showVol = spec.showVolume; | |
| 169 | + let subH = 0; | |
| 170 | + const volH = showVol ? Math.max(52, Math.min(110, plotH * 0.16)) : 0; | |
| 171 | + const indH = subs.length ? Math.max(72, Math.min(140, plotH * 0.18)) : 0; | |
| 172 | + subH = volH + indH * subs.length; | |
| 173 | + const mainH = Math.max(80, plotH - subH); | |
| 174 | + let y = 0; | |
| 175 | + panes.push({ id: 'main', kind: 'price', rect: { x: 0, y, w: plotW, h: mainH } }); | |
| 176 | + y += mainH; | |
| 177 | + if (showVol) { panes.push({ id: 'volume', kind: 'volume', rect: { x: 0, y, w: plotW, h: volH } }); y += volH; } | |
| 178 | + for (const s of subs) { panes.push({ ...s, rect: { x: 0, y, w: plotW, h: indH } }); y += indH; } | |
| 179 | + return panes; | |
| 180 | + } | |
| 181 | + | |
| 182 | + _xScale(rect) { | |
| 183 | + const bw = rect.w / this.view.count; | |
| 184 | + const first = this.view.first; | |
| 185 | + return { bw, X: i => rect.x + (i - first + 0.5) * bw }; | |
| 186 | + } | |
| 187 | + | |
| 188 | + _iAtX(x, rect) { | |
| 189 | + const bw = rect.w / this.view.count; | |
| 190 | + return Math.round(this.view.first + (x - rect.x) / bw - 0.5); | |
| 191 | + } | |
| 192 | + | |
| 193 | + /* ----- échelle Y d'un pane ----- */ | |
| 194 | + _yScale(pane, i0, i1) { | |
| 195 | + const spec = this.spec; | |
| 196 | + const items = spec.items; | |
| 197 | + const pad = 0.08; | |
| 198 | + if (pane.kind === 'price') { | |
| 199 | + let lo = Infinity, hi = -Infinity; | |
| 200 | + for (let i = i0; i <= i1; i++) { | |
| 201 | + const b = items[i]; | |
| 202 | + if (!b) continue; | |
| 203 | + if (b.l < lo) lo = b.l; | |
| 204 | + if (b.h > hi) hi = b.h; | |
| 205 | + } | |
| 206 | + const extraArrs = []; | |
| 207 | + for (const ov of (spec.overlays || [])) { | |
| 208 | + if (ov.band) extraArrs.push(ov.band.up, ov.band.lo); | |
| 209 | + else extraArrs.push(ov.vals); | |
| 210 | + } | |
| 211 | + for (const cs of (spec.compare || [])) extraArrs.push(cs.vals); | |
| 212 | + for (const arr of extraArrs) { | |
| 213 | + if (!arr) continue; | |
| 214 | + for (let i = i0; i <= i1; i++) { | |
| 215 | + const v = arr[i]; | |
| 216 | + if (v == null) continue; | |
| 217 | + if (v < lo) lo = v; | |
| 218 | + if (v > hi) hi = v; | |
| 219 | + } | |
| 220 | + } | |
| 221 | + if (spec.anchor != null) { lo = Math.min(lo, spec.anchor); hi = Math.max(hi, spec.anchor); } | |
| 222 | + if (!isFinite(lo) || !isFinite(hi)) { lo = 0; hi = 1; } | |
| 223 | + if (hi - lo < 1e-9) { hi += 1; lo -= 1; } | |
| 224 | + const r = pane.rect; | |
| 225 | + if (spec.logScale && lo > 0) { | |
| 226 | + const llo = Math.log(lo), lhi = Math.log(hi); | |
| 227 | + const span = (lhi - llo) || 1; | |
| 228 | + const p = span * pad; | |
| 229 | + return { Y: v => r.y + r.h - ((Math.log(Math.max(v, 1e-12)) - (llo - p)) / (span + 2 * p)) * r.h, lo, hi, log: true }; | |
| 230 | + } | |
| 231 | + const span = hi - lo; | |
| 232 | + const p = span * pad; | |
| 233 | + return { Y: v => r.y + r.h - ((v - (lo - p)) / (span + 2 * p)) * r.h, lo, hi, log: false }; | |
| 234 | + } | |
| 235 | + if (pane.kind === 'volume') { | |
| 236 | + let maxV = 0; | |
| 237 | + for (let i = i0; i <= i1; i++) { const b = items[i]; if (b && b.v > maxV) maxV = b.v; } | |
| 238 | + return { maxV: maxV || 1 }; | |
| 239 | + } | |
| 240 | + if (pane.kind === 'rsi') { | |
| 241 | + const r = pane.rect; | |
| 242 | + return { Y: v => r.y + r.h - (v / 100) * r.h, lo: 0, hi: 100 }; | |
| 243 | + } | |
| 244 | + // macd / atr : autoscale sur les valeurs du pane | |
| 245 | + let lo = Infinity, hi = -Infinity; | |
| 246 | + const arrs = pane.kind === 'macd' ? [pane.data.line, pane.data.signal, pane.data.hist] : [pane.data]; | |
| 247 | + for (const arr of arrs) { | |
| 248 | + for (let i = i0; i <= i1; i++) { | |
| 249 | + const v = arr[i]; | |
| 250 | + if (v == null) continue; | |
| 251 | + if (v < lo) lo = v; | |
| 252 | + if (v > hi) hi = v; | |
| 253 | + } | |
| 254 | + } | |
| 255 | + if (!isFinite(lo)) { lo = 0; hi = 1; } | |
| 256 | + if (pane.kind === 'macd') { const m = Math.max(Math.abs(lo), Math.abs(hi), 1e-9); lo = -m; hi = m; } | |
| 257 | + if (hi - lo < 1e-9) { hi += 1; lo -= 1; } | |
| 258 | + const r = pane.rect; | |
| 259 | + const p = (hi - lo) * 0.12; | |
| 260 | + return { Y: v => r.y + r.h - ((v - (lo - p)) / ((hi - lo) + 2 * p)) * r.h, lo, hi }; | |
| 261 | + } | |
| 262 | + | |
| 263 | + /* ----- ticks de temps ----- */ | |
| 264 | + _timeTicks(i0, i1, bw) { | |
| 265 | + const items = this.spec.items; | |
| 266 | + if (i1 <= i0) return []; | |
| 267 | + // tf médian | |
| 268 | + const deltas = []; | |
| 269 | + for (let i = Math.max(1, i0); i <= Math.min(i1, i0 + 60); i++) { | |
| 270 | + if (items[i] && items[i - 1]) deltas.push(items[i].t - items[i - 1].t); | |
| 271 | + } | |
| 272 | + deltas.sort((a, b) => a - b); | |
| 273 | + const tf = deltas[Math.floor(deltas.length / 2)] || 60; | |
| 274 | + const targetSec = (92 / bw) * tf; | |
| 275 | + let unit = TIME_LADDER[TIME_LADDER.length - 1]; | |
| 276 | + for (const u of TIME_LADDER) { | |
| 277 | + const sec = typeof u === 'number' ? u | |
| 278 | + : u === 'M' ? 30 * 86400 : u === '3M' ? 91 * 86400 : u === '6M' ? 182 * 86400 | |
| 279 | + : u === 'Y' ? 365 * 86400 : u === '2Y' ? 730 * 86400 : u === '5Y' ? 1826 * 86400 : 3652 * 86400; | |
| 280 | + if (sec >= targetSec) { unit = u; break; } | |
| 281 | + } | |
| 282 | + const ticks = []; | |
| 283 | + let lastKey = null, lastPx = -1e9; | |
| 284 | + const keyOf = t => { | |
| 285 | + const d = new Date(t * 1000); | |
| 286 | + if (typeof unit === 'number') return Math.floor(t / unit); | |
| 287 | + const y = d.getUTCFullYear(), m = d.getUTCMonth(); | |
| 288 | + if (unit === 'M') return y * 12 + m; | |
| 289 | + if (unit === '3M') return y * 4 + Math.floor(m / 3); | |
| 290 | + if (unit === '6M') return y * 2 + Math.floor(m / 6); | |
| 291 | + if (unit === 'Y') return y; | |
| 292 | + if (unit === '2Y') return Math.floor(y / 2); | |
| 293 | + if (unit === '5Y') return Math.floor(y / 5); | |
| 294 | + return Math.floor(y / 10); | |
| 295 | + }; | |
| 296 | + for (let i = Math.max(0, i0); i <= i1; i++) { | |
| 297 | + const b = items[i]; | |
| 298 | + if (!b) continue; | |
| 299 | + const k = keyOf(b.t); | |
| 300 | + if (k === lastKey) continue; | |
| 301 | + const px = (i - this.view.first + 0.5) * bw; | |
| 302 | + const d = new Date(b.t * 1000); | |
| 303 | + const prev = items[i - 1] ? new Date(items[i - 1].t * 1000) : null; | |
| 304 | + // force des frontières : année > mois > reste (une année ne saute jamais | |
| 305 | + // au profit d'un simple tick de mois trop proche) | |
| 306 | + const strength = !prev ? 0 | |
| 307 | + : prev.getUTCFullYear() !== d.getUTCFullYear() ? 2 | |
| 308 | + : prev.getUTCMonth() !== d.getUTCMonth() ? 1 : 0; | |
| 309 | + if (lastKey !== null && px - lastPx < 68) { | |
| 310 | + lastKey = k; | |
| 311 | + const lastTick = ticks[ticks.length - 1]; | |
| 312 | + if (!(lastTick && strength > lastTick.strength)) continue; | |
| 313 | + ticks.pop(); // la frontière forte remplace le tick faible trop proche | |
| 314 | + } | |
| 315 | + lastKey = k; lastPx = px; | |
| 316 | + if (ticks.length === 0 && px < 8) continue; | |
| 317 | + let label; | |
| 318 | + if (typeof unit === 'number' && unit < 86400) { | |
| 319 | + label = (!prev || prev.getUTCDate() !== d.getUTCDate()) | |
| 320 | + ? `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}` | |
| 321 | + : `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`; | |
| 322 | + } else if (typeof unit === 'number') { | |
| 323 | + label = (!prev || prev.getUTCFullYear() !== d.getUTCFullYear()) | |
| 324 | + ? String(d.getUTCFullYear()) | |
| 325 | + : (prev.getUTCMonth() !== d.getUTCMonth()) | |
| 326 | + ? MONTHS[d.getUTCMonth()] | |
| 327 | + : `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`; | |
| 328 | + } else if (unit === 'M' || unit === '3M' || unit === '6M') { | |
| 329 | + label = d.getUTCMonth() === 0 ? String(d.getUTCFullYear()) : MONTHS[d.getUTCMonth()]; | |
| 330 | + } else { | |
| 331 | + label = String(d.getUTCFullYear()); | |
| 332 | + } | |
| 333 | + ticks.push({ i, label, strength }); | |
| 334 | + } | |
| 335 | + return ticks; | |
| 336 | + } | |
| 337 | + | |
| 338 | + _priceTicks(scale, rect) { | |
| 339 | + const ticks = []; | |
| 340 | + const n = Math.max(2, Math.floor(rect.h / 56)); | |
| 341 | + if (scale.log && scale.hi / scale.lo > 3) { | |
| 342 | + // décades 1-2-5 en échelle log | |
| 343 | + let p = Math.pow(10, Math.floor(Math.log10(scale.lo))); | |
| 344 | + const mults = [1, 2, 5]; | |
| 345 | + for (let dec = 0; dec < 20 && p < scale.hi * 10; dec++, p *= 10) { | |
| 346 | + for (const m of mults) { | |
| 347 | + const v = m * p; | |
| 348 | + if (v >= scale.lo && v <= scale.hi) ticks.push(v); | |
| 349 | + } | |
| 350 | + } | |
| 351 | + return ticks; | |
| 352 | + } | |
| 353 | + const step = niceStep((scale.hi - scale.lo) / n); | |
| 354 | + for (let v = Math.ceil(scale.lo / step) * step; v <= scale.hi + step * 1e-6; v += step) ticks.push(v); | |
| 355 | + return ticks; | |
| 356 | + } | |
| 357 | + | |
| 358 | + /* ----- rendu principal ----- */ | |
| 359 | + render() { | |
| 360 | + const ctx = this.base.getContext('2d'); | |
| 361 | + ctx.clearRect(0, 0, this.w, this.h); | |
| 362 | + const th = this.th; | |
| 363 | + ctx.fillStyle = th.surface; | |
| 364 | + ctx.fillRect(0, 0, this.w, this.h); | |
| 365 | + if (!this.spec || !this.spec.items.length) { | |
| 366 | + ctx.fillStyle = th.muted; | |
| 367 | + ctx.font = '13px system-ui, -apple-system, sans-serif'; | |
| 368 | + ctx.textAlign = 'center'; | |
| 369 | + ctx.fillText('Chargement…', this.w / 2, this.h / 2); | |
| 370 | + this._renderOverlay(); | |
| 371 | + return; | |
| 372 | + } | |
| 373 | + const spec = this.spec; | |
| 374 | + // largeur d'axe adaptée au prix courant (jamais de label rogné) | |
| 375 | + { | |
| 376 | + const lastBar = spec.items[spec.items.length - 1]; | |
| 377 | + ctx.font = '600 11px system-ui, -apple-system, sans-serif'; | |
| 378 | + const w = lastBar ? ctx.measureText(fmtPrice(lastBar.c, spec.decimals) + (spec.percent ? ' %' : '')).width : 50; | |
| 379 | + this.axisW = Math.max(46, Math.min(96, Math.ceil(w) + 16)); | |
| 380 | + } | |
| 381 | + const panes = this._layout(); | |
| 382 | + this.panes = panes; | |
| 383 | + const [i0, i1] = this.visibleRange(); | |
| 384 | + const main = panes[0]; | |
| 385 | + const { bw, X } = this._xScale(main.rect); | |
| 386 | + this._bw = bw; this._X = X; | |
| 387 | + const font = '11px system-ui, -apple-system, sans-serif'; | |
| 388 | + | |
| 389 | + // ticks temps (partagés) | |
| 390 | + const tticks = this._timeTicks(i0, i1, bw); | |
| 391 | + | |
| 392 | + for (const pane of panes) { | |
| 393 | + const r = pane.rect; | |
| 394 | + const scale = this._yScale(pane, i0, i1); | |
| 395 | + pane.scale = scale; | |
| 396 | + ctx.save(); | |
| 397 | + ctx.beginPath(); | |
| 398 | + ctx.rect(r.x, r.y, r.w, r.h); | |
| 399 | + ctx.clip(); | |
| 400 | + | |
| 401 | + // grille verticale (plus discrète que l'horizontale) | |
| 402 | + ctx.strokeStyle = th.alpha(th.grid, 0.6); | |
| 403 | + ctx.lineWidth = 1; | |
| 404 | + ctx.beginPath(); | |
| 405 | + for (const tk of tticks) { | |
| 406 | + const x = Math.round(X(tk.i)) + 0.5; | |
| 407 | + ctx.moveTo(x, r.y); | |
| 408 | + ctx.lineTo(x, r.y + r.h); | |
| 409 | + } | |
| 410 | + ctx.stroke(); | |
| 411 | + | |
| 412 | + const env = { ctx, items: spec.items, i0, i1, X, Y: scale.Y, rect: r, bw, th, anchor: spec.anchor }; | |
| 413 | + | |
| 414 | + if (pane.kind === 'price') { | |
| 415 | + // grille horizontale + labels | |
| 416 | + const pticks = this._priceTicks(scale, r); | |
| 417 | + ctx.beginPath(); | |
| 418 | + ctx.strokeStyle = th.grid; | |
| 419 | + for (const v of pticks) { | |
| 420 | + const y = Math.round(scale.Y(v)) + 0.5; | |
| 421 | + ctx.moveTo(r.x, y); ctx.lineTo(r.x + r.w, y); | |
| 422 | + } | |
| 423 | + ctx.stroke(); | |
| 424 | + pane.pticks = pticks; | |
| 425 | + | |
| 426 | + // filigrane symbole (repère d'identité, très en retrait) | |
| 427 | + if (spec.meta && spec.meta.symbol && r.h > 90) { | |
| 428 | + ctx.save(); | |
| 429 | + const size = Math.min(r.h * 0.30, r.w * 0.13, 110); | |
| 430 | + ctx.font = `700 ${size}px system-ui, -apple-system, sans-serif`; | |
| 431 | + ctx.fillStyle = th.alpha(th.ink, 0.045); | |
| 432 | + ctx.textAlign = 'center'; | |
| 433 | + ctx.textBaseline = 'middle'; | |
| 434 | + const wm = spec.meta.tfLabel ? `${spec.meta.symbol} · ${spec.meta.tfLabel}` : spec.meta.symbol; | |
| 435 | + ctx.fillText(wm, r.x + r.w / 2, r.y + r.h / 2); | |
| 436 | + ctx.restore(); | |
| 437 | + } | |
| 438 | + | |
| 439 | + // bandes (Bollinger / VWAP) sous la série | |
| 440 | + for (const ov of (spec.overlays || [])) { | |
| 441 | + if (ov.band) HF.render.bandFill(env, ov.band.up, ov.band.lo, th.alpha(ov.color, 0.10)); | |
| 442 | + } | |
| 443 | + // profil de volume (avant la série pour rester en fond) | |
| 444 | + if (spec.volumeProfile) this._drawProfile(ctx, env, i0, i1); | |
| 445 | + // série principale ('none' = mode comparaison, lignes uniquement) | |
| 446 | + if (spec.draw !== 'none') (HF.render[spec.draw] || HF.render.candles)(env); | |
| 447 | + // lignes d'overlays | |
| 448 | + for (const ov of (spec.overlays || [])) { | |
| 449 | + if (ov.band) { | |
| 450 | + HF.render.indicatorLine(env, ov.band.up, ov.color, 1, [3, 3]); | |
| 451 | + HF.render.indicatorLine(env, ov.band.lo, ov.color, 1, [3, 3]); | |
| 452 | + if (ov.band.mid) HF.render.indicatorLine(env, ov.band.mid, ov.color, 1.5); | |
| 453 | + } else if (ov.vals) { | |
| 454 | + HF.render.indicatorLine(env, ov.vals, ov.color, ov.width || 1.75, ov.dash); | |
| 455 | + } | |
| 456 | + } | |
| 457 | + // lignes de comparaison | |
| 458 | + if (spec.compare) { | |
| 459 | + for (const cs of spec.compare) { | |
| 460 | + HF.render.indicatorLine(env, cs.vals, cs.color, 2); | |
| 461 | + } | |
| 462 | + } | |
| 463 | + } else if (pane.kind === 'volume') { | |
| 464 | + HF.render.volumeColumns(env, scale.maxV); | |
| 465 | + } else if (pane.kind === 'rsi') { | |
| 466 | + for (const lvl of [30, 70]) { | |
| 467 | + ctx.strokeStyle = th.grid; | |
| 468 | + ctx.setLineDash([3, 3]); | |
| 469 | + ctx.beginPath(); | |
| 470 | + const y = Math.round(scale.Y(lvl)) + 0.5; | |
| 471 | + ctx.moveTo(r.x, y); ctx.lineTo(r.x + r.w, y); | |
| 472 | + ctx.stroke(); | |
| 473 | + ctx.setLineDash([]); | |
| 474 | + } | |
| 475 | + ctx.fillStyle = th.alpha(th.series[0], 0.06); | |
| 476 | + ctx.fillRect(r.x, scale.Y(70), r.w, scale.Y(30) - scale.Y(70)); | |
| 477 | + HF.render.indicatorLine(env, pane.data, th.series[0], 2); | |
| 478 | + } else if (pane.kind === 'macd') { | |
| 479 | + const zero = Math.round(scale.Y(0)) + 0.5; | |
| 480 | + ctx.strokeStyle = th.baselineAxis; | |
| 481 | + ctx.beginPath(); ctx.moveTo(r.x, zero); ctx.lineTo(r.x + r.w, zero); ctx.stroke(); | |
| 482 | + const w = Math.max(1, Math.min(bw - 2, Math.round(bw * 0.7))); | |
| 483 | + for (const pos of [true, false]) { | |
| 484 | + ctx.fillStyle = th.alpha(pos ? th.up : th.dn, 0.45); | |
| 485 | + ctx.beginPath(); | |
| 486 | + for (let i = i0; i <= i1; i++) { | |
| 487 | + const v = pane.data.hist[i]; | |
| 488 | + if (v == null || (v >= 0) !== pos) continue; | |
| 489 | + const y = scale.Y(v); | |
| 490 | + ctx.rect(Math.round(X(i) - w / 2), Math.min(y, zero), w, Math.abs(y - zero) || 1); | |
| 491 | + } | |
| 492 | + ctx.fill(); | |
| 493 | + } | |
| 494 | + HF.render.indicatorLine(env, pane.data.line, th.series[0], 1.75); | |
| 495 | + HF.render.indicatorLine(env, pane.data.signal, th.series[1], 1.75); | |
| 496 | + } else if (pane.kind === 'atr') { | |
| 497 | + HF.render.indicatorLine(env, pane.data, th.series[2], 2); | |
| 498 | + } | |
| 499 | + ctx.restore(); | |
| 500 | + | |
| 501 | + // séparateur de pane | |
| 502 | + if (pane !== panes[0]) { | |
| 503 | + ctx.strokeStyle = th.baselineAxis; | |
| 504 | + ctx.beginPath(); | |
| 505 | + ctx.moveTo(0, Math.round(r.y) + 0.5); | |
| 506 | + ctx.lineTo(this.w, Math.round(r.y) + 0.5); | |
| 507 | + ctx.stroke(); | |
| 508 | + } | |
| 509 | + } | |
| 510 | + | |
| 511 | + // ---- axe des prix (pane principal) + labels des sous-panes ---- | |
| 512 | + ctx.font = font; | |
| 513 | + ctx.textBaseline = 'middle'; | |
| 514 | + ctx.textAlign = 'left'; | |
| 515 | + const axisX = this.w - this.axisW; | |
| 516 | + ctx.strokeStyle = th.baselineAxis; | |
| 517 | + ctx.beginPath(); | |
| 518 | + ctx.moveTo(Math.round(axisX) + 0.5, 0); | |
| 519 | + ctx.lineTo(Math.round(axisX) + 0.5, this.h - this.axisH); | |
| 520 | + ctx.stroke(); | |
| 521 | + ctx.fillStyle = th.muted; | |
| 522 | + const pctSuffix = spec.percent ? ' %' : ''; | |
| 523 | + for (const v of (main.pticks || [])) { | |
| 524 | + const y = main.scale.Y(v); | |
| 525 | + if (y < main.rect.y + 8 || y > main.rect.y + main.rect.h - 8) continue; | |
| 526 | + ctx.fillText(fmtPrice(v, this._tickDecimals(main.pticks)) + pctSuffix, axisX + 6, y); | |
| 527 | + } | |
| 528 | + // labels min/max + titre des sous-panes | |
| 529 | + for (const pane of panes.slice(1)) { | |
| 530 | + const r = pane.rect; | |
| 531 | + ctx.fillStyle = th.muted; | |
| 532 | + if (pane.kind === 'volume') { | |
| 533 | + ctx.fillText(fmtVol(pane.scale.maxV), axisX + 6, r.y + 10); | |
| 534 | + } else if (pane.scale && pane.scale.Y) { | |
| 535 | + ctx.fillText(fmtPrice(pane.scale.hi, pane.kind === 'rsi' ? 0 : 2), axisX + 6, r.y + 10); | |
| 536 | + ctx.fillText(fmtPrice(pane.scale.lo, pane.kind === 'rsi' ? 0 : 2), axisX + 6, r.y + r.h - 10); | |
| 537 | + } | |
| 538 | + const title = pane.kind === 'volume' ? 'Volume' : (pane.label || pane.kind.toUpperCase()); | |
| 539 | + ctx.font = '600 10px system-ui, -apple-system, sans-serif'; | |
| 540 | + ctx.fillStyle = th.ink2; | |
| 541 | + ctx.textAlign = 'left'; | |
| 542 | + ctx.fillText(title, 8, r.y + 11); | |
| 543 | + ctx.font = font; | |
| 544 | + } | |
| 545 | + | |
| 546 | + // ---- axe du temps ---- | |
| 547 | + const axisY = Math.round(this.h - this.axisH) + 0.5; | |
| 548 | + ctx.strokeStyle = th.baselineAxis; | |
| 549 | + ctx.beginPath(); | |
| 550 | + ctx.moveTo(0, axisY); | |
| 551 | + ctx.lineTo(this.w, axisY); | |
| 552 | + for (const tk of tticks) { // petites graduations | |
| 553 | + const x = Math.round(X(tk.i)) + 0.5; | |
| 554 | + if (x > 4 && x < axisX - 4) { ctx.moveTo(x, axisY); ctx.lineTo(x, axisY + 4); } | |
| 555 | + } | |
| 556 | + ctx.stroke(); | |
| 557 | + ctx.fillStyle = th.muted; | |
| 558 | + ctx.textAlign = 'center'; | |
| 559 | + for (const tk of tticks) { | |
| 560 | + const x = X(tk.i); | |
| 561 | + if (x > 4 && x < axisX - 4) ctx.fillText(tk.label, x, this.h - this.axisH / 2 + 2); | |
| 562 | + } | |
| 563 | + | |
| 564 | + // ---- dernier prix (chip sur l'axe, sans objet en mode comparaison) ---- | |
| 565 | + const last = spec.items[spec.items.length - 1]; | |
| 566 | + if (last && main.scale && !spec.compare) { | |
| 567 | + const prev = spec.items[spec.items.length - 2]; | |
| 568 | + const up = prev ? last.c >= prev.c : true; | |
| 569 | + const y = Math.max(main.rect.y + 9, Math.min(main.rect.y + main.rect.h - 9, main.scale.Y(last.c))); | |
| 570 | + // ligne pointillée du dernier prix | |
| 571 | + ctx.strokeStyle = th.alpha(up ? th.up : th.dn, 0.55); | |
| 572 | + ctx.setLineDash([2, 4]); | |
| 573 | + ctx.beginPath(); | |
| 574 | + ctx.moveTo(0, Math.round(main.scale.Y(last.c)) + 0.5); | |
| 575 | + ctx.lineTo(axisX, Math.round(main.scale.Y(last.c)) + 0.5); | |
| 576 | + ctx.stroke(); | |
| 577 | + ctx.setLineDash([]); | |
| 578 | + ctx.save(); | |
| 579 | + ctx.shadowColor = 'rgba(0,0,0,0.25)'; | |
| 580 | + ctx.shadowBlur = 6; | |
| 581 | + ctx.shadowOffsetY = 1; | |
| 582 | + ctx.fillStyle = up ? th.up : th.dn; | |
| 583 | + const label = fmtPrice(last.c, spec.decimals); | |
| 584 | + const tw = ctx.measureText(label).width; | |
| 585 | + HF.render.roundRect(ctx, axisX + 2, y - 9, Math.max(tw + 12, this.axisW - 6), 18, 4); | |
| 586 | + ctx.fill(); | |
| 587 | + ctx.restore(); | |
| 588 | + ctx.fillStyle = '#ffffff'; | |
| 589 | + ctx.textAlign = 'left'; | |
| 590 | + ctx.font = '600 11px system-ui, -apple-system, sans-serif'; | |
| 591 | + ctx.fillText(label, axisX + 8, y + 0.5); | |
| 592 | + ctx.font = font; | |
| 593 | + } | |
| 594 | + | |
| 595 | + // labels de fin de ligne pour la comparaison (identité ≠ couleur seule) | |
| 596 | + if (spec.compare && main.scale) { | |
| 597 | + ctx.textAlign = 'left'; | |
| 598 | + ctx.font = 'bold 11px system-ui, -apple-system, sans-serif'; | |
| 599 | + const used = []; | |
| 600 | + for (const cs of spec.compare) { | |
| 601 | + let li = i1; | |
| 602 | + while (li >= i0 && cs.vals[li] == null) li--; | |
| 603 | + if (li < i0) continue; | |
| 604 | + let y = main.scale.Y(cs.vals[li]); | |
| 605 | + y = Math.max(main.rect.y + 8, Math.min(main.rect.y + main.rect.h - 8, y)); | |
| 606 | + while (used.some(u => Math.abs(u - y) < 14)) y += 14; | |
| 607 | + used.push(y); | |
| 608 | + const tw = ctx.measureText(cs.label).width; | |
| 609 | + const lx = Math.min(X(li) + 13, axisX - tw - 4); // jamais rogné par l'axe | |
| 610 | + ctx.fillStyle = cs.color; | |
| 611 | + ctx.beginPath(); | |
| 612 | + ctx.arc(lx - 7, y, 3.5, 0, Math.PI * 2); | |
| 613 | + ctx.fill(); | |
| 614 | + ctx.fillText(cs.label, lx, y); | |
| 615 | + } | |
| 616 | + ctx.font = font; | |
| 617 | + } | |
| 618 | + | |
| 619 | + this._renderOverlay(); | |
| 620 | + } | |
| 621 | + | |
| 622 | + _tickDecimals(ticks) { | |
| 623 | + if (!ticks || ticks.length < 2) return this.spec.decimals; | |
| 624 | + const step = Math.abs(ticks[1] - ticks[0]); | |
| 625 | + if (step >= 1) return Math.min(this.spec.decimals, step >= 10 ? 0 : 1); | |
| 626 | + return Math.min(6, Math.max(this.spec.decimals, Math.ceil(-Math.log10(step)))); | |
| 627 | + } | |
| 628 | + | |
| 629 | + _drawProfile(ctx, env, i0, i1) { | |
| 630 | + const prof = HF.ind.volumeProfile(this.spec.items, i0, i1, 26); | |
| 631 | + if (!prof) return; | |
| 632 | + const r = env.rect; | |
| 633 | + const th = this.th; | |
| 634 | + const maxW = r.w * 0.22; | |
| 635 | + prof.rows.forEach((row, k) => { | |
| 636 | + const y0 = env.Y(row.p1), y1 = env.Y(row.p0); | |
| 637 | + const h = Math.max(1, y1 - y0 - 1); | |
| 638 | + const wUp = (row.up / prof.max) * maxW; | |
| 639 | + const wDn = (row.dn / prof.max) * maxW; | |
| 640 | + const xR = r.x + r.w; | |
| 641 | + ctx.fillStyle = th.alpha(th.up, k === prof.poc ? 0.5 : 0.22); | |
| 642 | + ctx.fillRect(xR - wUp - wDn, y0, wUp, h); | |
| 643 | + ctx.fillStyle = th.alpha(th.dn, k === prof.poc ? 0.5 : 0.22); | |
| 644 | + ctx.fillRect(xR - wDn, y0, wDn, h); | |
| 645 | + }); | |
| 646 | + // ligne POC | |
| 647 | + const poc = prof.rows[prof.poc]; | |
| 648 | + const yP = env.Y((poc.p0 + poc.p1) / 2); | |
| 649 | + ctx.strokeStyle = th.alpha(th.ink, 0.4); | |
| 650 | + ctx.setLineDash([5, 4]); | |
| 651 | + ctx.beginPath(); | |
| 652 | + ctx.moveTo(r.x, Math.round(yP) + 0.5); | |
| 653 | + ctx.lineTo(r.x + r.w, Math.round(yP) + 0.5); | |
| 654 | + ctx.stroke(); | |
| 655 | + ctx.setLineDash([]); | |
| 656 | + } | |
| 657 | + | |
| 658 | + /* ----- overlay : crosshair + chips ----- */ | |
| 659 | + _renderOverlay() { | |
| 660 | + const ctx = this.over.getContext('2d'); | |
| 661 | + ctx.clearRect(0, 0, this.w, this.h); | |
| 662 | + if (!this.cursor || !this.spec || !this.spec.items.length || !this.panes) { | |
| 663 | + if (this.opts.onCrosshair) this.opts.onCrosshair(null, null); | |
| 664 | + return; | |
| 665 | + } | |
| 666 | + const th = this.th; | |
| 667 | + const { x, y } = this.cursor; | |
| 668 | + const main = this.panes[0]; | |
| 669 | + const i = Math.max(0, Math.min(this.spec.items.length - 1, this._iAtX(x, main.rect))); | |
| 670 | + const item = this.spec.items[i]; | |
| 671 | + const cx = Math.round(this._X(i)) + 0.5; | |
| 672 | + const axisX = this.w - this.axisW; | |
| 673 | + ctx.strokeStyle = th.alpha(th.ink, 0.45); | |
| 674 | + ctx.setLineDash([4, 4]); | |
| 675 | + ctx.lineWidth = 1; | |
| 676 | + ctx.beginPath(); | |
| 677 | + ctx.moveTo(cx, 0); | |
| 678 | + ctx.lineTo(cx, this.h - this.axisH); | |
| 679 | + ctx.stroke(); | |
| 680 | + // ligne horizontale dans le pane sous le curseur | |
| 681 | + const pane = this.panes.find(p => y >= p.rect.y && y <= p.rect.y + p.rect.h); | |
| 682 | + if (pane) { | |
| 683 | + ctx.beginPath(); | |
| 684 | + ctx.moveTo(0, Math.round(y) + 0.5); | |
| 685 | + ctx.lineTo(axisX, Math.round(y) + 0.5); | |
| 686 | + ctx.stroke(); | |
| 687 | + } | |
| 688 | + ctx.setLineDash([]); | |
| 689 | + ctx.font = '11px system-ui, -apple-system, sans-serif'; | |
| 690 | + ctx.textBaseline = 'middle'; | |
| 691 | + // chip prix | |
| 692 | + if (pane && pane.kind === 'price' && pane.scale) { | |
| 693 | + const s = pane.scale; | |
| 694 | + let v; | |
| 695 | + if (s.log) { | |
| 696 | + const r = pane.rect; | |
| 697 | + const span = Math.log(s.hi) - Math.log(s.lo) || 1; | |
| 698 | + const p = span * 0.08; | |
| 699 | + v = Math.exp((Math.log(s.lo) - p) + (1 - (y - r.y) / r.h) * (span + 2 * p)); | |
| 700 | + } else { | |
| 701 | + const r = pane.rect; | |
| 702 | + const span = s.hi - s.lo, p = span * 0.08; | |
| 703 | + v = (s.lo - p) + (1 - (y - r.y) / r.h) * (span + 2 * p); | |
| 704 | + } | |
| 705 | + const label = fmtPrice(v, this.spec.decimals) + (this.spec.percent ? ' %' : ''); | |
| 706 | + ctx.save(); | |
| 707 | + ctx.shadowColor = 'rgba(0,0,0,0.22)'; | |
| 708 | + ctx.shadowBlur = 5; | |
| 709 | + ctx.fillStyle = th.chip; | |
| 710 | + const tw = ctx.measureText(label).width; | |
| 711 | + HF.render.roundRect(ctx, axisX + 2, y - 9, Math.max(tw + 12, this.axisW - 6), 18, 4); | |
| 712 | + ctx.fill(); | |
| 713 | + ctx.restore(); | |
| 714 | + ctx.fillStyle = th.chipText; | |
| 715 | + ctx.textAlign = 'left'; | |
| 716 | + ctx.fillText(label, axisX + 8, y + 0.5); | |
| 717 | + } | |
| 718 | + // chip temps | |
| 719 | + if (item) { | |
| 720 | + const label = fmtTimeFull(item.t); | |
| 721 | + const tw = ctx.measureText(label).width + 14; | |
| 722 | + const bx = Math.max(2, Math.min(this.w - tw - 2, cx - tw / 2)); | |
| 723 | + ctx.save(); | |
| 724 | + ctx.shadowColor = 'rgba(0,0,0,0.22)'; | |
| 725 | + ctx.shadowBlur = 5; | |
| 726 | + ctx.fillStyle = th.chip; | |
| 727 | + HF.render.roundRect(ctx, bx, this.h - this.axisH + 2, tw, this.axisH - 5, 4); | |
| 728 | + ctx.fill(); | |
| 729 | + ctx.restore(); | |
| 730 | + ctx.fillStyle = th.chipText; | |
| 731 | + ctx.textAlign = 'center'; | |
| 732 | + ctx.fillText(label, bx + tw / 2, this.h - this.axisH / 2); | |
| 733 | + // point sur les types "ligne" | |
| 734 | + if (['line', 'step', 'area', 'baseline'].includes(this.spec.draw) && main.scale) { | |
| 735 | + ctx.fillStyle = th.accent; | |
| 736 | + ctx.beginPath(); | |
| 737 | + ctx.arc(this._X(i), main.scale.Y(item.c), 4, 0, Math.PI * 2); | |
| 738 | + ctx.fill(); | |
| 739 | + ctx.strokeStyle = th.surface; | |
| 740 | + ctx.lineWidth = 2; | |
| 741 | + ctx.stroke(); | |
| 742 | + } | |
| 743 | + } | |
| 744 | + if (this.opts.onCrosshair) this.opts.onCrosshair(i, item); | |
| 745 | + } | |
| 746 | + | |
| 747 | + /* ----- interactions ----- */ | |
| 748 | + _bindEvents() { | |
| 749 | + const el = this.over; | |
| 750 | + let drag = null; | |
| 751 | + let pinch = null; | |
| 752 | + const pts = new Map(); // pointerId → position (pinch tactile à 2 doigts) | |
| 753 | + const pinchDist = () => { | |
| 754 | + const [a, b] = [...pts.values()]; | |
| 755 | + return Math.max(12, Math.hypot(a.x - b.x, a.y - b.y)); | |
| 756 | + }; | |
| 757 | + el.addEventListener('pointerdown', e => { | |
| 758 | + pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); | |
| 759 | + el.setPointerCapture(e.pointerId); | |
| 760 | + if (pts.size === 2) { | |
| 761 | + drag = null; | |
| 762 | + const r = el.getBoundingClientRect(); | |
| 763 | + const [a, b] = [...pts.values()]; | |
| 764 | + const midX = (a.x + b.x) / 2 - r.left; | |
| 765 | + const plotW = this.w - this.axisW; | |
| 766 | + pinch = { | |
| 767 | + dist: pinchDist(), | |
| 768 | + count: this.view.count, | |
| 769 | + frac: Math.max(0, Math.min(1, midX / plotW)), | |
| 770 | + iMid: this.view.first + (midX / plotW) * this.view.count, | |
| 771 | + }; | |
| 772 | + } else { | |
| 773 | + drag = { x: e.clientX, first: this.view.first }; | |
| 774 | + el.style.cursor = 'grabbing'; | |
| 775 | + } | |
| 776 | + }); | |
| 777 | + const endPointer = e => { | |
| 778 | + pts.delete(e.pointerId); | |
| 779 | + if (pts.size < 2) pinch = null; | |
| 780 | + if (pts.size === 0) drag = null; | |
| 781 | + el.style.cursor = 'crosshair'; | |
| 782 | + }; | |
| 783 | + el.addEventListener('pointerup', endPointer); | |
| 784 | + el.addEventListener('pointercancel', endPointer); | |
| 785 | + el.addEventListener('pointermove', e => { | |
| 786 | + if (pts.has(e.pointerId)) pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); | |
| 787 | + const r = el.getBoundingClientRect(); | |
| 788 | + this.cursor = { x: e.clientX - r.left, y: e.clientY - r.top }; | |
| 789 | + if (pinch && pts.size === 2) { | |
| 790 | + const scale = pinch.dist / pinchDist(); | |
| 791 | + this.view.count = pinch.count * scale; | |
| 792 | + this.view.first = pinch.iMid - pinch.frac * this.view.count; | |
| 793 | + this._clampView(); | |
| 794 | + this.render(); | |
| 795 | + if (this.opts.onViewChange) this.opts.onViewChange(this.view); | |
| 796 | + } else if (drag) { | |
| 797 | + const bw = (this.w - this.axisW) / this.view.count; | |
| 798 | + this.view.first = drag.first - (e.clientX - drag.x) / bw; | |
| 799 | + this._clampView(); | |
| 800 | + this.render(); | |
| 801 | + if (this.opts.onViewChange) this.opts.onViewChange(this.view); | |
| 802 | + } else { | |
| 803 | + this._renderOverlay(); | |
| 804 | + } | |
| 805 | + }); | |
| 806 | + el.addEventListener('pointerleave', () => { | |
| 807 | + this.cursor = null; | |
| 808 | + this._renderOverlay(); | |
| 809 | + }); | |
| 810 | + el.addEventListener('wheel', e => { | |
| 811 | + e.preventDefault(); | |
| 812 | + if (!this.spec) return; | |
| 813 | + const r = el.getBoundingClientRect(); | |
| 814 | + const x = e.clientX - r.left; | |
| 815 | + if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { | |
| 816 | + // pan horizontal (trackpad) | |
| 817 | + const bw = (this.w - this.axisW) / this.view.count; | |
| 818 | + this.view.first += e.deltaX / bw; | |
| 819 | + } else { | |
| 820 | + const factor = Math.exp(e.deltaY * 0.0016); | |
| 821 | + const iAt = this.view.first + (x / (this.w - this.axisW)) * this.view.count; | |
| 822 | + const newCount = this.view.count * factor; | |
| 823 | + this.view.count = newCount; | |
| 824 | + this.view.first = iAt - (x / (this.w - this.axisW)) * newCount; | |
| 825 | + } | |
| 826 | + this._clampView(); | |
| 827 | + this.render(); | |
| 828 | + if (this.opts.onViewChange) this.opts.onViewChange(this.view); | |
| 829 | + }, { passive: false }); | |
| 830 | + el.addEventListener('dblclick', () => this.resetView()); | |
| 831 | + } | |
| 832 | + | |
| 833 | + destroy() { | |
| 834 | + this._ro.disconnect(); | |
| 835 | + this.el.innerHTML = ''; | |
| 836 | + } | |
| 837 | + } | |
| 838 | + | |
| 839 | + /* Mini-rendu statique pour la galerie (pas d'axes, pas d'interaction). */ | |
| 840 | + function mini(canvas, draw, items, th, extras) { | |
| 841 | + const dpr = window.devicePixelRatio || 1; | |
| 842 | + const w = canvas.clientWidth || 220, h = canvas.clientHeight || 110; | |
| 843 | + canvas.width = w * dpr; canvas.height = h * dpr; | |
| 844 | + const ctx = canvas.getContext('2d'); | |
| 845 | + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| 846 | + ctx.clearRect(0, 0, w, h); | |
| 847 | + if (!items || !items.length) return; | |
| 848 | + let lo = Infinity, hi = -Infinity; | |
| 849 | + for (const b of items) { lo = Math.min(lo, b.l); hi = Math.max(hi, b.h); } | |
| 850 | + if (!(hi > lo)) return; | |
| 851 | + const pad = (hi - lo) * 0.06; | |
| 852 | + const rect = { x: 0, y: 0, w, h }; | |
| 853 | + const bw = w / items.length; | |
| 854 | + const env = { | |
| 855 | + ctx, items, i0: 0, i1: items.length - 1, | |
| 856 | + X: i => (i + 0.5) * bw, | |
| 857 | + Y: v => h - ((v - (lo - pad)) / ((hi - lo) + 2 * pad)) * h, | |
| 858 | + rect, bw, th, | |
| 859 | + anchor: extras && extras.anchor != null ? extras.anchor : items[0].c, | |
| 860 | + }; | |
| 861 | + (HF.render[draw] || HF.render.candles)(env); | |
| 862 | + } | |
| 863 | + | |
| 864 | + HF.HFChart = HFChart; | |
| 865 | + HF.mini = mini; | |
| 866 | + HF.fmt = { price: fmtPrice, vol: fmtVol, time: fmtTimeFull, MONTHS, DAYS }; | |
| 867 | +})(); | |
added
web/js/indicators.js
+164 −0
@@ -0,0 +1,164 @@ | ||
| 1 | +/* ============================================================================ | |
| 2 | + * HFChart — indicateurs techniques, calculés from scratch | |
| 3 | + * Author : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | + * ---------------------------------------------------------------------------- | |
| 5 | + * Toutes les fonctions retournent des tableaux alignés sur l'index des barres | |
| 6 | + * (null avant la période de chauffe). | |
| 7 | + * ==========================================================================*/ | |
| 8 | +(function () { | |
| 9 | + 'use strict'; | |
| 10 | + const HF = (window.HF = window.HF || {}); | |
| 11 | + | |
| 12 | + const closes = bars => bars.map(b => b.c); | |
| 13 | + | |
| 14 | + function sma(vals, n) { | |
| 15 | + const out = new Array(vals.length).fill(null); | |
| 16 | + let sum = 0; | |
| 17 | + for (let i = 0; i < vals.length; i++) { | |
| 18 | + sum += vals[i]; | |
| 19 | + if (i >= n) sum -= vals[i - n]; | |
| 20 | + if (i >= n - 1) out[i] = sum / n; | |
| 21 | + } | |
| 22 | + return out; | |
| 23 | + } | |
| 24 | + | |
| 25 | + function ema(vals, n) { | |
| 26 | + const out = new Array(vals.length).fill(null); | |
| 27 | + const k = 2 / (n + 1); | |
| 28 | + let prev = null, seed = 0; | |
| 29 | + for (let i = 0; i < vals.length; i++) { | |
| 30 | + if (i < n - 1) { seed += vals[i]; continue; } | |
| 31 | + if (i === n - 1) { prev = (seed + vals[i]) / n; out[i] = prev; continue; } | |
| 32 | + prev = vals[i] * k + prev * (1 - k); | |
| 33 | + out[i] = prev; | |
| 34 | + } | |
| 35 | + return out; | |
| 36 | + } | |
| 37 | + | |
| 38 | + function bollinger(bars, n, mult) { | |
| 39 | + n = n || 20; mult = mult || 2; | |
| 40 | + const c = closes(bars); | |
| 41 | + const mid = sma(c, n); | |
| 42 | + const up = new Array(c.length).fill(null); | |
| 43 | + const lo = new Array(c.length).fill(null); | |
| 44 | + let sum = 0, sumSq = 0; | |
| 45 | + for (let i = 0; i < c.length; i++) { | |
| 46 | + sum += c[i]; sumSq += c[i] * c[i]; | |
| 47 | + if (i >= n) { sum -= c[i - n]; sumSq -= c[i - n] * c[i - n]; } | |
| 48 | + if (i >= n - 1) { | |
| 49 | + const mean = sum / n; | |
| 50 | + const sd = Math.sqrt(Math.max(0, sumSq / n - mean * mean)); | |
| 51 | + up[i] = mean + mult * sd; | |
| 52 | + lo[i] = mean - mult * sd; | |
| 53 | + } | |
| 54 | + } | |
| 55 | + return { mid, up, lo }; | |
| 56 | + } | |
| 57 | + | |
| 58 | + function rsi(bars, n) { | |
| 59 | + n = n || 14; | |
| 60 | + const c = closes(bars); | |
| 61 | + const out = new Array(c.length).fill(null); | |
| 62 | + let avgG = 0, avgL = 0; | |
| 63 | + for (let i = 1; i < c.length; i++) { | |
| 64 | + const d = c[i] - c[i - 1]; | |
| 65 | + const g = d > 0 ? d : 0, l = d < 0 ? -d : 0; | |
| 66 | + if (i <= n) { | |
| 67 | + avgG += g / n; avgL += l / n; | |
| 68 | + if (i === n) out[i] = avgL === 0 ? 100 : 100 - 100 / (1 + avgG / avgL); | |
| 69 | + } else { | |
| 70 | + avgG = (avgG * (n - 1) + g) / n; | |
| 71 | + avgL = (avgL * (n - 1) + l) / n; | |
| 72 | + out[i] = avgL === 0 ? 100 : 100 - 100 / (1 + avgG / avgL); | |
| 73 | + } | |
| 74 | + } | |
| 75 | + return out; | |
| 76 | + } | |
| 77 | + | |
| 78 | + function macd(bars, fast, slow, signal) { | |
| 79 | + fast = fast || 12; slow = slow || 26; signal = signal || 9; | |
| 80 | + const c = closes(bars); | |
| 81 | + const ef = ema(c, fast), es = ema(c, slow); | |
| 82 | + const line = c.map((_, i) => (ef[i] != null && es[i] != null) ? ef[i] - es[i] : null); | |
| 83 | + // EMA du signal sur la portion définie | |
| 84 | + const start = line.findIndex(v => v != null); | |
| 85 | + const sig = new Array(c.length).fill(null); | |
| 86 | + if (start >= 0) { | |
| 87 | + const seg = ema(line.slice(start), signal); | |
| 88 | + for (let i = 0; i < seg.length; i++) sig[start + i] = seg[i]; | |
| 89 | + } | |
| 90 | + const hist = line.map((v, i) => (v != null && sig[i] != null) ? v - sig[i] : null); | |
| 91 | + return { line, signal: sig, hist }; | |
| 92 | + } | |
| 93 | + | |
| 94 | + function atr(bars, n) { | |
| 95 | + n = n || 14; | |
| 96 | + const out = new Array(bars.length).fill(null); | |
| 97 | + let prev = null; | |
| 98 | + for (let i = 1; i < bars.length; i++) { | |
| 99 | + const b = bars[i], p = bars[i - 1]; | |
| 100 | + const tr = Math.max(b.h - b.l, Math.abs(b.h - p.c), Math.abs(b.l - p.c)); | |
| 101 | + if (i <= n) { | |
| 102 | + prev = (prev || 0) + tr / n; | |
| 103 | + if (i === n) out[i] = prev; | |
| 104 | + } else { | |
| 105 | + prev = (prev * (n - 1) + tr) / n; | |
| 106 | + out[i] = prev; | |
| 107 | + } | |
| 108 | + } | |
| 109 | + return out; | |
| 110 | + } | |
| 111 | + | |
| 112 | + /* VWAP de session (reset à chaque journée) + bandes ±1σ / ±2σ. | |
| 113 | + * Nécessite du volume — désactivé pour les indices. */ | |
| 114 | + function vwapSession(bars) { | |
| 115 | + const v = new Array(bars.length).fill(null); | |
| 116 | + const up1 = v.slice(), dn1 = v.slice(), up2 = v.slice(), dn2 = v.slice(); | |
| 117 | + let day = null, cumV = 0, cumPV = 0, cumP2V = 0; | |
| 118 | + for (let i = 0; i < bars.length; i++) { | |
| 119 | + const b = bars[i]; | |
| 120 | + const d = Math.floor(b.t / 86400); | |
| 121 | + if (d !== day) { day = d; cumV = 0; cumPV = 0; cumP2V = 0; } | |
| 122 | + const tp = (b.h + b.l + b.c) / 3; | |
| 123 | + cumV += b.v; cumPV += tp * b.v; cumP2V += tp * tp * b.v; | |
| 124 | + if (cumV > 0) { | |
| 125 | + const w = cumPV / cumV; | |
| 126 | + const sd = Math.sqrt(Math.max(0, cumP2V / cumV - w * w)); | |
| 127 | + v[i] = w; up1[i] = w + sd; dn1[i] = w - sd; up2[i] = w + 2 * sd; dn2[i] = w - 2 * sd; | |
| 128 | + } | |
| 129 | + } | |
| 130 | + return { v, up1, dn1, up2, dn2 }; | |
| 131 | + } | |
| 132 | + | |
| 133 | + /* Profil de volume sur une plage d'items visibles. | |
| 134 | + * Retourne {rows:[{p0,p1,up,dn,total}], max, poc} — poc = index de la ligne max. */ | |
| 135 | + function volumeProfile(bars, i0, i1, nRows) { | |
| 136 | + nRows = nRows || 24; | |
| 137 | + let lo = Infinity, hi = -Infinity; | |
| 138 | + for (let i = i0; i <= i1; i++) { const b = bars[i]; if (!b) continue; lo = Math.min(lo, b.l); hi = Math.max(hi, b.h); } | |
| 139 | + if (!(hi > lo)) return null; | |
| 140 | + const rows = Array.from({ length: nRows }, (_, k) => ({ | |
| 141 | + p0: lo + (k / nRows) * (hi - lo), p1: lo + ((k + 1) / nRows) * (hi - lo), up: 0, dn: 0, total: 0, | |
| 142 | + })); | |
| 143 | + const step = (hi - lo) / nRows; | |
| 144 | + for (let i = i0; i <= i1; i++) { | |
| 145 | + const b = bars[i]; | |
| 146 | + if (!b || !(b.v > 0)) continue; | |
| 147 | + // volume réparti uniformément sur la plage h..l de la barre | |
| 148 | + const span = Math.max(b.h - b.l, step * 0.5); | |
| 149 | + const k0 = Math.max(0, Math.floor((b.l - lo) / step)); | |
| 150 | + const k1 = Math.min(nRows - 1, Math.floor((b.h - lo) / step)); | |
| 151 | + const per = b.v / (k1 - k0 + 1); | |
| 152 | + const isUp = b.c >= b.o; | |
| 153 | + for (let k = k0; k <= k1; k++) { | |
| 154 | + rows[k].total += per; | |
| 155 | + if (isUp) rows[k].up += per; else rows[k].dn += per; | |
| 156 | + } | |
| 157 | + } | |
| 158 | + let max = 0, poc = 0; | |
| 159 | + rows.forEach((r, k) => { if (r.total > max) { max = r.total; poc = k; } }); | |
| 160 | + return { rows, max, poc }; | |
| 161 | + } | |
| 162 | + | |
| 163 | + HF.ind = { sma, ema, bollinger, rsi, macd, atr, vwapSession, volumeProfile, closes }; | |
| 164 | +})(); | |
added
web/js/renderers.js
+348 −0
@@ -0,0 +1,348 @@ | ||
| 1 | +/* ============================================================================ | |
| 2 | + * HFChart — renderers canvas, from scratch | |
| 3 | + * Author : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | + * ==========================================================================*/ | |
| 5 | +/* Chaque renderer reçoit un environnement : | |
| 6 | + * { ctx, items, i0, i1, X(i)→px centre, Y(prix)→px, rect, bw, th } | |
| 7 | + * th = tokens du thème (up, dn, ink, grid, series[], surface, …). | |
| 8 | + * Specs de marques : corps fins avec 2px d'écart entre barres adjacentes, | |
| 9 | + * lignes 2px, extrémités arrondies discrètes. */ | |
| 10 | +(function () { | |
| 11 | + 'use strict'; | |
| 12 | + const HF = (window.HF = window.HF || {}); | |
| 13 | + | |
| 14 | + function bodyWidth(bw) { | |
| 15 | + // écart de 2px garanti entre corps adjacents dès que la place existe | |
| 16 | + return Math.max(1, Math.min(bw - 2, Math.round(bw * 0.8))); | |
| 17 | + } | |
| 18 | + | |
| 19 | + function roundRect(ctx, x, y, w, h, r) { | |
| 20 | + if (h < 0) { y += h; h = -h; } | |
| 21 | + r = Math.min(r, w / 2, h / 2); | |
| 22 | + if (r < 0.5) { ctx.rect(x, y, w, h); return; } | |
| 23 | + ctx.moveTo(x + r, y); | |
| 24 | + ctx.arcTo(x + w, y, x + w, y + h, r); | |
| 25 | + ctx.arcTo(x + w, y + h, x, y + h, r); | |
| 26 | + ctx.arcTo(x, y + h, x, y, r); | |
| 27 | + ctx.arcTo(x, y, x + w, y, r); | |
| 28 | + ctx.closePath(); | |
| 29 | + } | |
| 30 | + | |
| 31 | + /* Fast path haute densité : 1 trait hi-lo par barre quand bw < 2px. */ | |
| 32 | + function denseHiLo(env) { | |
| 33 | + const { ctx, items, i0, i1, X, Y, th } = env; | |
| 34 | + ctx.lineWidth = 1; | |
| 35 | + for (const up of [true, false]) { | |
| 36 | + ctx.strokeStyle = up ? th.up : th.dn; | |
| 37 | + ctx.beginPath(); | |
| 38 | + for (let i = i0; i <= i1; i++) { | |
| 39 | + const b = items[i]; | |
| 40 | + if (!b || (b.c >= b.o) !== up) continue; | |
| 41 | + const x = Math.round(X(i)) + 0.5; | |
| 42 | + ctx.moveTo(x, Y(b.h)); | |
| 43 | + ctx.lineTo(x, Y(b.l)); | |
| 44 | + } | |
| 45 | + ctx.stroke(); | |
| 46 | + } | |
| 47 | + } | |
| 48 | + | |
| 49 | + function candles(env, hollow) { | |
| 50 | + const { ctx, items, i0, i1, X, Y, bw, th } = env; | |
| 51 | + if (bw < 2) return denseHiLo(env); | |
| 52 | + const w = bodyWidth(bw); | |
| 53 | + const wick = bw >= 10 ? 1.5 : 1; | |
| 54 | + const r = w >= 6 ? 1.5 : 0; | |
| 55 | + for (const up of [true, false]) { | |
| 56 | + const col = up ? th.up : th.dn; | |
| 57 | + // mèches | |
| 58 | + ctx.strokeStyle = col; | |
| 59 | + ctx.lineWidth = wick; | |
| 60 | + ctx.beginPath(); | |
| 61 | + for (let i = i0; i <= i1; i++) { | |
| 62 | + const b = items[i]; | |
| 63 | + if (!b || (b.up !== undefined ? b.up : b.c >= b.o) !== up) continue; | |
| 64 | + const x = Math.round(X(i)) + (wick === 1 ? 0.5 : 0); | |
| 65 | + const top = Math.max(b.o, b.c), bot = Math.min(b.o, b.c); | |
| 66 | + if (b.h > top) { ctx.moveTo(x, Y(b.h)); ctx.lineTo(x, Y(top)); } | |
| 67 | + if (b.l < bot) { ctx.moveTo(x, Y(bot)); ctx.lineTo(x, Y(b.l)); } | |
| 68 | + } | |
| 69 | + ctx.stroke(); | |
| 70 | + // corps | |
| 71 | + ctx.beginPath(); | |
| 72 | + for (let i = i0; i <= i1; i++) { | |
| 73 | + const b = items[i]; | |
| 74 | + if (!b || (b.up !== undefined ? b.up : b.c >= b.o) !== up) continue; | |
| 75 | + const x = Math.round(X(i) - w / 2); | |
| 76 | + const y0 = Y(b.o), y1 = Y(b.c); | |
| 77 | + const h = Math.abs(y1 - y0) < 1 ? 1 : y1 - y0; | |
| 78 | + roundRect(ctx, x, y0, w, h, r); | |
| 79 | + } | |
| 80 | + if (hollow && up) { | |
| 81 | + ctx.strokeStyle = col; | |
| 82 | + ctx.lineWidth = Math.min(2, Math.max(1, w / 5)); | |
| 83 | + ctx.stroke(); | |
| 84 | + } else { | |
| 85 | + ctx.fillStyle = col; | |
| 86 | + ctx.fill(); | |
| 87 | + } | |
| 88 | + } | |
| 89 | + } | |
| 90 | + | |
| 91 | + function ohlcBars(env) { | |
| 92 | + const { ctx, items, i0, i1, X, Y, bw, th } = env; | |
| 93 | + if (bw < 3) return denseHiLo(env); | |
| 94 | + const tick = Math.max(2, Math.min(bw * 0.4, 8)); | |
| 95 | + ctx.lineWidth = bw >= 8 ? 1.5 : 1; | |
| 96 | + for (const up of [true, false]) { | |
| 97 | + ctx.strokeStyle = up ? th.up : th.dn; | |
| 98 | + ctx.beginPath(); | |
| 99 | + for (let i = i0; i <= i1; i++) { | |
| 100 | + const b = items[i]; | |
| 101 | + if (!b || (b.c >= b.o) !== up) continue; | |
| 102 | + const x = Math.round(X(i)) + 0.5; | |
| 103 | + ctx.moveTo(x, Y(b.h)); ctx.lineTo(x, Y(b.l)); | |
| 104 | + ctx.moveTo(x - tick, Y(b.o)); ctx.lineTo(x, Y(b.o)); | |
| 105 | + ctx.moveTo(x, Y(b.c)); ctx.lineTo(x + tick, Y(b.c)); | |
| 106 | + } | |
| 107 | + ctx.stroke(); | |
| 108 | + } | |
| 109 | + } | |
| 110 | + | |
| 111 | + function linePath(env, color, width) { | |
| 112 | + const { ctx, items, i0, i1, X, Y } = env; | |
| 113 | + ctx.strokeStyle = color; | |
| 114 | + ctx.lineWidth = width || 2; | |
| 115 | + ctx.lineJoin = 'round'; | |
| 116 | + ctx.lineCap = 'round'; | |
| 117 | + ctx.beginPath(); | |
| 118 | + let started = false; | |
| 119 | + for (let i = i0; i <= i1; i++) { | |
| 120 | + const b = items[i]; | |
| 121 | + if (!b) continue; | |
| 122 | + const x = X(i), y = Y(b.c); | |
| 123 | + if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y); | |
| 124 | + } | |
| 125 | + ctx.stroke(); | |
| 126 | + } | |
| 127 | + | |
| 128 | + function line(env) { linePath(env, env.th.accent, 2); } | |
| 129 | + | |
| 130 | + function step(env) { | |
| 131 | + const { ctx, items, i0, i1, X, Y, th } = env; | |
| 132 | + ctx.strokeStyle = th.accent; | |
| 133 | + ctx.lineWidth = 2; | |
| 134 | + ctx.lineJoin = 'round'; | |
| 135 | + ctx.beginPath(); | |
| 136 | + let started = false, py = 0; | |
| 137 | + for (let i = i0; i <= i1; i++) { | |
| 138 | + const b = items[i]; | |
| 139 | + if (!b) continue; | |
| 140 | + const x = X(i), y = Y(b.c); | |
| 141 | + if (!started) { ctx.moveTo(x, y); started = true; } | |
| 142 | + else { ctx.lineTo(x, py); ctx.lineTo(x, y); } | |
| 143 | + py = y; | |
| 144 | + } | |
| 145 | + ctx.stroke(); | |
| 146 | + } | |
| 147 | + | |
| 148 | + function area(env) { | |
| 149 | + const { ctx, items, i0, i1, X, Y, rect, th } = env; | |
| 150 | + const grad = ctx.createLinearGradient(0, rect.y, 0, rect.y + rect.h); | |
| 151 | + grad.addColorStop(0, th.alpha(th.accent, 0.30)); | |
| 152 | + grad.addColorStop(0.55, th.alpha(th.accent, 0.10)); | |
| 153 | + grad.addColorStop(1, th.alpha(th.accent, 0)); | |
| 154 | + ctx.beginPath(); | |
| 155 | + let started = false, lastX = 0, firstX = 0; | |
| 156 | + for (let i = i0; i <= i1; i++) { | |
| 157 | + const b = items[i]; | |
| 158 | + if (!b) continue; | |
| 159 | + const x = X(i), y = Y(b.c); | |
| 160 | + if (!started) { ctx.moveTo(x, y); firstX = x; started = true; } else ctx.lineTo(x, y); | |
| 161 | + lastX = x; | |
| 162 | + } | |
| 163 | + if (!started) return; | |
| 164 | + ctx.lineTo(lastX, rect.y + rect.h); | |
| 165 | + ctx.lineTo(firstX, rect.y + rect.h); | |
| 166 | + ctx.closePath(); | |
| 167 | + ctx.fillStyle = grad; | |
| 168 | + ctx.fill(); | |
| 169 | + linePath(env, th.accent, 2); | |
| 170 | + } | |
| 171 | + | |
| 172 | + /* Baseline : ancre = close de la première barre chargée (env.anchor). | |
| 173 | + * Au-dessus → up, en dessous → dn (aires + segments de ligne). */ | |
| 174 | + function baseline(env) { | |
| 175 | + const { ctx, items, i0, i1, X, Y, rect, th } = env; | |
| 176 | + const anchor = env.anchor; | |
| 177 | + const yA = Math.max(rect.y, Math.min(rect.y + rect.h, Y(anchor))); | |
| 178 | + for (const side of ['up', 'dn']) { | |
| 179 | + ctx.save(); | |
| 180 | + ctx.beginPath(); | |
| 181 | + if (side === 'up') ctx.rect(rect.x, rect.y, rect.w, yA - rect.y); | |
| 182 | + else ctx.rect(rect.x, yA, rect.w, rect.y + rect.h - yA); | |
| 183 | + ctx.clip(); | |
| 184 | + const col = side === 'up' ? th.up : th.dn; | |
| 185 | + const grad = side === 'up' | |
| 186 | + ? ctx.createLinearGradient(0, rect.y, 0, yA) | |
| 187 | + : ctx.createLinearGradient(0, yA, 0, rect.y + rect.h); | |
| 188 | + grad.addColorStop(side === 'up' ? 0 : 1, th.alpha(col, 0.28)); | |
| 189 | + grad.addColorStop(side === 'up' ? 1 : 0, th.alpha(col, 0.02)); | |
| 190 | + // aire fermée sur la baseline | |
| 191 | + ctx.beginPath(); | |
| 192 | + let started = false, lastX = 0, firstX = 0; | |
| 193 | + for (let i = i0; i <= i1; i++) { | |
| 194 | + const b = items[i]; | |
| 195 | + if (!b) continue; | |
| 196 | + const x = X(i), y = Y(b.c); | |
| 197 | + if (!started) { ctx.moveTo(x, y); firstX = x; started = true; } else ctx.lineTo(x, y); | |
| 198 | + lastX = x; | |
| 199 | + } | |
| 200 | + if (started) { | |
| 201 | + ctx.lineTo(lastX, yA); ctx.lineTo(firstX, yA); ctx.closePath(); | |
| 202 | + ctx.fillStyle = grad; ctx.fill(); | |
| 203 | + linePath(env, col, 2); | |
| 204 | + } | |
| 205 | + ctx.restore(); | |
| 206 | + } | |
| 207 | + // trait d'ancre | |
| 208 | + ctx.strokeStyle = th.baselineAxis; | |
| 209 | + ctx.setLineDash([4, 4]); | |
| 210 | + ctx.lineWidth = 1; | |
| 211 | + ctx.beginPath(); | |
| 212 | + ctx.moveTo(rect.x, Math.round(yA) + 0.5); | |
| 213 | + ctx.lineTo(rect.x + rect.w, Math.round(yA) + 0.5); | |
| 214 | + ctx.stroke(); | |
| 215 | + ctx.setLineDash([]); | |
| 216 | + } | |
| 217 | + | |
| 218 | + /* Briques pleines (Renko, Line Break, Range) — chandelles sans mèche. */ | |
| 219 | + function bricks(env) { candles(env, false); } | |
| 220 | + | |
| 221 | + function kagiDraw(env) { | |
| 222 | + const { ctx, items, i0, i1, X, Y, th } = env; | |
| 223 | + for (const thick of [false, true]) { | |
| 224 | + ctx.strokeStyle = thick ? th.up : th.dn; | |
| 225 | + ctx.lineWidth = thick ? 2.5 : 1.25; | |
| 226 | + ctx.lineJoin = 'miter'; | |
| 227 | + ctx.beginPath(); | |
| 228 | + for (let i = Math.max(1, i0); i <= i1; i++) { | |
| 229 | + const b = items[i]; | |
| 230 | + if (!b || b.up !== thick) continue; | |
| 231 | + const x = Math.round(X(i)) + 0.5; | |
| 232 | + const xPrev = Math.round(X(i - 1)) + 0.5; | |
| 233 | + ctx.moveTo(xPrev, Y(b.o)); // épaule horizontale depuis la ligne précédente | |
| 234 | + ctx.lineTo(x, Y(b.o)); | |
| 235 | + ctx.lineTo(x, Y(b.c)); // ligne verticale | |
| 236 | + } | |
| 237 | + const first = items[i0]; | |
| 238 | + if (first && first.up === thick && i0 === 0) { | |
| 239 | + ctx.moveTo(Math.round(X(0)) + 0.5, Y(first.o)); | |
| 240 | + ctx.lineTo(Math.round(X(0)) + 0.5, Y(first.c)); | |
| 241 | + } | |
| 242 | + ctx.stroke(); | |
| 243 | + } | |
| 244 | + } | |
| 245 | + | |
| 246 | + function pnfDraw(env) { | |
| 247 | + const { ctx, items, i0, i1, X, Y, bw, th } = env; | |
| 248 | + if (bw < 3) return denseHiLo(env); | |
| 249 | + const w = bodyWidth(bw); | |
| 250 | + ctx.lineWidth = Math.max(1, Math.min(2, w / 6)); | |
| 251 | + for (let i = i0; i <= i1; i++) { | |
| 252 | + const col = items[i]; | |
| 253 | + if (!col || !col.box) continue; | |
| 254 | + const x = X(i); | |
| 255 | + const glyphW = Math.min(w, Math.abs(Y(col.lo) - Y(col.lo + col.box)) * 0.9); | |
| 256 | + ctx.strokeStyle = col.type === 'X' ? th.up : th.dn; | |
| 257 | + ctx.beginPath(); | |
| 258 | + for (let k = 0; k < col.n; k++) { | |
| 259 | + const p0 = col.lo + k * col.box; | |
| 260 | + const yB = Y(p0), yT = Y(p0 + col.box); | |
| 261 | + const half = Math.min(glyphW / 2, Math.abs(yB - yT) / 2 - 1); | |
| 262 | + if (half < 1) { // trop dense : trait plein | |
| 263 | + ctx.moveTo(x, yB); ctx.lineTo(x, yT); | |
| 264 | + continue; | |
| 265 | + } | |
| 266 | + const cy = (yB + yT) / 2; | |
| 267 | + if (col.type === 'X') { | |
| 268 | + ctx.moveTo(x - half, cy + half); ctx.lineTo(x + half, cy - half); | |
| 269 | + ctx.moveTo(x - half, cy - half); ctx.lineTo(x + half, cy + half); | |
| 270 | + } else { | |
| 271 | + ctx.moveTo(x + half, cy); | |
| 272 | + ctx.arc(x, cy, half, 0, Math.PI * 2); | |
| 273 | + } | |
| 274 | + } | |
| 275 | + ctx.stroke(); | |
| 276 | + } | |
| 277 | + } | |
| 278 | + | |
| 279 | + /* Colonnes de volume, colorées par direction de la barre (alpha pour rester | |
| 280 | + * une couche secondaire), 2px d'écart. */ | |
| 281 | + function volumeColumns(env, maxV) { | |
| 282 | + const { ctx, items, i0, i1, X, rect, bw, th } = env; | |
| 283 | + const w = Math.max(1, Math.min(bw - 2, Math.round(bw * 0.8))); | |
| 284 | + const y0 = rect.y + rect.h; | |
| 285 | + for (const up of [true, false]) { | |
| 286 | + // dégradé vertical : plus dense à la base, léger vers le haut du pane | |
| 287 | + const col = up ? th.up : th.dn; | |
| 288 | + const grad = ctx.createLinearGradient(0, rect.y, 0, y0); | |
| 289 | + grad.addColorStop(0, th.alpha(col, 0.28)); | |
| 290 | + grad.addColorStop(1, th.alpha(col, 0.6)); | |
| 291 | + ctx.fillStyle = grad; | |
| 292 | + ctx.beginPath(); | |
| 293 | + for (let i = i0; i <= i1; i++) { | |
| 294 | + const b = items[i]; | |
| 295 | + if (!b || !(b.v > 0) || (b.up !== undefined ? b.up : b.c >= b.o) !== up) continue; | |
| 296 | + const h = Math.max(1, (b.v / maxV) * (rect.h - 4)); | |
| 297 | + roundRect(ctx, Math.round(X(i) - w / 2), y0 - h, w, h, w >= 6 ? 2 : 0); | |
| 298 | + } | |
| 299 | + ctx.fill(); | |
| 300 | + } | |
| 301 | + } | |
| 302 | + | |
| 303 | + /* Ligne d'indicateur (overlay ou pane) sur un tableau de valeurs alignées. */ | |
| 304 | + function indicatorLine(env, vals, color, width, dash) { | |
| 305 | + const { ctx, i0, i1, X, Y } = env; | |
| 306 | + ctx.strokeStyle = color; | |
| 307 | + ctx.lineWidth = width || 2; | |
| 308 | + ctx.lineJoin = 'round'; | |
| 309 | + if (dash) ctx.setLineDash(dash); | |
| 310 | + ctx.beginPath(); | |
| 311 | + let started = false; | |
| 312 | + for (let i = i0; i <= i1; i++) { | |
| 313 | + const v = vals[i]; | |
| 314 | + if (v == null) { continue; } | |
| 315 | + const x = X(i), y = Y(v); | |
| 316 | + if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y); | |
| 317 | + } | |
| 318 | + ctx.stroke(); | |
| 319 | + if (dash) ctx.setLineDash([]); | |
| 320 | + } | |
| 321 | + | |
| 322 | + /* Remplissage entre deux tableaux (bandes Bollinger / VWAP). */ | |
| 323 | + function bandFill(env, upVals, loVals, color) { | |
| 324 | + const { ctx, i0, i1, X, Y } = env; | |
| 325 | + ctx.fillStyle = color; | |
| 326 | + ctx.beginPath(); | |
| 327 | + let started = false; | |
| 328 | + const pts = []; | |
| 329 | + for (let i = i0; i <= i1; i++) { | |
| 330 | + if (upVals[i] == null || loVals[i] == null) continue; | |
| 331 | + const x = X(i); | |
| 332 | + if (!started) { ctx.moveTo(x, Y(upVals[i])); started = true; } else ctx.lineTo(x, Y(upVals[i])); | |
| 333 | + pts.push([x, Y(loVals[i])]); | |
| 334 | + } | |
| 335 | + for (let k = pts.length - 1; k >= 0; k--) ctx.lineTo(pts[k][0], pts[k][1]); | |
| 336 | + if (started) { ctx.closePath(); ctx.fill(); } | |
| 337 | + } | |
| 338 | + | |
| 339 | + HF.render = { | |
| 340 | + candles: env => candles(env, false), | |
| 341 | + hollow: env => candles(env, true), | |
| 342 | + ohlc: ohlcBars, | |
| 343 | + line, step, area, baseline, bricks, | |
| 344 | + kagi: kagiDraw, | |
| 345 | + pnf: pnfDraw, | |
| 346 | + volumeColumns, indicatorLine, bandFill, linePath, roundRect, denseHiLo, | |
| 347 | + }; | |
| 348 | +})(); | |
added
web/js/transforms.js
+208 −0
@@ -0,0 +1,208 @@ | ||
| 1 | +/* ============================================================================ | |
| 2 | + * HFChart — transformations de séries (from scratch, zéro dépendance) | |
| 3 | + * Author : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | + * ---------------------------------------------------------------------------- | |
| 5 | + * Un "bar" = {t,o,h,l,c,v}. Chaque transform retourne un tableau de barres | |
| 6 | + * pseudo-temporelles ; l'axe X reste l'index de barre, comme sur tout chart HF. | |
| 7 | + * ==========================================================================*/ | |
| 8 | +(function () { | |
| 9 | + 'use strict'; | |
| 10 | + const HF = (window.HF = window.HF || {}); | |
| 11 | + | |
| 12 | + /* Arrondi "joli" (1 / 2 / 2.5 / 5 × 10^n) — sert de taille de brique auto. */ | |
| 13 | + function niceRound(x) { | |
| 14 | + if (!(x > 0)) return 1; | |
| 15 | + const p = Math.pow(10, Math.floor(Math.log10(x))); | |
| 16 | + const m = x / p; | |
| 17 | + const step = m < 1.5 ? 1 : m < 2.25 ? 2 : m < 3.5 ? 2.5 : m < 7.5 ? 5 : 10; | |
| 18 | + return step * p; | |
| 19 | + } | |
| 20 | + | |
| 21 | + /* Brique auto façon ATR : moyenne des true ranges des ~500 dernières barres. */ | |
| 22 | + function autoBrick(bars, k) { | |
| 23 | + const n = bars.length; | |
| 24 | + if (n < 3) return niceRound((n ? bars[n - 1].c : 1) * 0.005); | |
| 25 | + let sum = 0, cnt = 0; | |
| 26 | + for (let i = Math.max(1, n - 500); i < n; i++) { | |
| 27 | + const b = bars[i], p = bars[i - 1]; | |
| 28 | + sum += Math.max(b.h - b.l, Math.abs(b.h - p.c), Math.abs(b.l - p.c)); | |
| 29 | + cnt++; | |
| 30 | + } | |
| 31 | + return niceRound((sum / cnt) * (k || 1)) || niceRound(bars[n - 1].c * 0.002); | |
| 32 | + } | |
| 33 | + | |
| 34 | + function heikinAshi(bars) { | |
| 35 | + const out = new Array(bars.length); | |
| 36 | + let po = 0, pc = 0; | |
| 37 | + for (let i = 0; i < bars.length; i++) { | |
| 38 | + const b = bars[i]; | |
| 39 | + const c = (b.o + b.h + b.l + b.c) / 4; | |
| 40 | + const o = i === 0 ? (b.o + b.c) / 2 : (po + pc) / 2; | |
| 41 | + out[i] = { t: b.t, o, h: Math.max(b.h, o, c), l: Math.min(b.l, o, c), c, v: b.v }; | |
| 42 | + po = o; pc = c; | |
| 43 | + } | |
| 44 | + return out; | |
| 45 | + } | |
| 46 | + | |
| 47 | + /* Renko : briques de taille fixe, renversement = 2 briques. */ | |
| 48 | + function renko(bars, opts) { | |
| 49 | + const brick = (opts && opts.brick) || autoBrick(bars, 1); | |
| 50 | + const out = []; | |
| 51 | + if (!bars.length || !(brick > 0)) return out; | |
| 52 | + let lo = Math.floor(bars[0].c / brick) * brick; // plancher de la brique courante | |
| 53 | + let hi = lo + brick; | |
| 54 | + let dir = 0, vol = 0; | |
| 55 | + const push = (t, up) => { | |
| 56 | + if (up) { out.push({ t, o: hi, c: hi + brick, h: hi + brick, l: hi, v: vol, up: true }); lo = hi; hi += brick; dir = 1; } | |
| 57 | + else { out.push({ t, o: lo, c: lo - brick, h: lo, l: lo - brick, v: vol, up: false }); hi = lo; lo -= brick; dir = -1; } | |
| 58 | + vol = 0; | |
| 59 | + }; | |
| 60 | + for (const b of bars) { | |
| 61 | + vol += b.v; | |
| 62 | + for (;;) { | |
| 63 | + if (b.c >= hi + brick - 1e-9 && dir >= 0) push(b.t, true); | |
| 64 | + else if (b.c <= lo - brick + 1e-9 && dir <= 0) push(b.t, false); | |
| 65 | + else if (dir > 0 && b.c <= lo - brick + 1e-9) push(b.t, false); // renversement (2 briques sous hi) | |
| 66 | + else if (dir < 0 && b.c >= hi + brick - 1e-9) push(b.t, true); // renversement haussier | |
| 67 | + else break; | |
| 68 | + } | |
| 69 | + } | |
| 70 | + return out; | |
| 71 | + } | |
| 72 | + | |
| 73 | + /* Three-Line Break : nouvelle barre seulement si le close casse les N dernières. */ | |
| 74 | + function lineBreak(bars, opts) { | |
| 75 | + const nBreak = (opts && opts.lines) || 3; | |
| 76 | + const out = []; | |
| 77 | + for (const b of bars) { | |
| 78 | + if (!out.length) { | |
| 79 | + if (b.c !== b.o) out.push({ t: b.t, o: b.o, c: b.c, h: Math.max(b.o, b.c), l: Math.min(b.o, b.c), v: b.v, up: b.c >= b.o }); | |
| 80 | + continue; | |
| 81 | + } | |
| 82 | + const last = out[out.length - 1]; | |
| 83 | + const win = out.slice(-nBreak); | |
| 84 | + let maxC = -Infinity, minC = Infinity; | |
| 85 | + for (const x of win) { maxC = Math.max(maxC, x.o, x.c); minC = Math.min(minC, x.o, x.c); } | |
| 86 | + if (b.c > maxC + 1e-9) { | |
| 87 | + const o = Math.max(last.o, last.c); | |
| 88 | + out.push({ t: b.t, o, c: b.c, h: b.c, l: o, v: b.v, up: true }); | |
| 89 | + } else if (b.c < minC - 1e-9) { | |
| 90 | + const o = Math.min(last.o, last.c); | |
| 91 | + out.push({ t: b.t, o, c: b.c, h: o, l: b.c, v: b.v, up: false }); | |
| 92 | + } | |
| 93 | + } | |
| 94 | + return out; | |
| 95 | + } | |
| 96 | + | |
| 97 | + /* Range Bars : chaque barre couvre exactement `range` de haut en bas. */ | |
| 98 | + function rangeBars(bars, opts) { | |
| 99 | + const range = (opts && opts.range) || autoBrick(bars, 1.5); | |
| 100 | + const out = []; | |
| 101 | + if (!bars.length || !(range > 0)) return out; | |
| 102 | + let cur = null; | |
| 103 | + for (const b of bars) { | |
| 104 | + for (const p of [b.o, b.h, b.l, b.c]) { | |
| 105 | + if (!cur) { cur = { t: b.t, o: p, h: p, l: p, c: p, v: 0 }; continue; } | |
| 106 | + cur.h = Math.max(cur.h, p); cur.l = Math.min(cur.l, p); | |
| 107 | + cur.c = p; cur.t = b.t; | |
| 108 | + if (cur.h - cur.l >= range - 1e-9) { | |
| 109 | + const up = cur.c >= cur.o; | |
| 110 | + const h = cur.l + range, l = cur.l; | |
| 111 | + const hh = up ? h : cur.h, ll = up ? cur.l : cur.h - range; | |
| 112 | + const close = up ? hh : ll; | |
| 113 | + out.push({ t: cur.t, o: cur.o, h: hh, l: ll, c: close, v: cur.v + b.v, up }); | |
| 114 | + cur = { t: b.t, o: close, h: close, l: close, c: close, v: 0 }; | |
| 115 | + } | |
| 116 | + } | |
| 117 | + if (cur) cur.v += b.v; | |
| 118 | + } | |
| 119 | + return out; | |
| 120 | + } | |
| 121 | + | |
| 122 | + /* Kagi : item = une ligne verticale {o: départ, c: arrivée, up: yang (épaisse)}. | |
| 123 | + * Passe yang au-dessus de l'épaule précédente, yin sous la taille précédente. */ | |
| 124 | + function kagi(bars, opts) { | |
| 125 | + const rev = (opts && opts.reversal) || autoBrick(bars, 3); | |
| 126 | + const out = []; | |
| 127 | + if (!bars.length || !(rev > 0)) return out; | |
| 128 | + let dir = 0, anchor = bars[0].c, extreme = bars[0].c, yang = true; | |
| 129 | + let prevShoulder = null, prevWaist = null; | |
| 130 | + for (const b of bars) { | |
| 131 | + const p = b.c; | |
| 132 | + if (dir === 0) { | |
| 133 | + extreme = p; | |
| 134 | + if (p >= anchor + rev) dir = 1; | |
| 135 | + else if (p <= anchor - rev) dir = -1; | |
| 136 | + continue; | |
| 137 | + } | |
| 138 | + if (dir > 0) { | |
| 139 | + if (p > extreme) extreme = p; | |
| 140 | + if (extreme - p >= rev) { | |
| 141 | + if (prevShoulder != null && extreme > prevShoulder) yang = true; | |
| 142 | + out.push({ t: b.t, o: anchor, c: extreme, h: extreme, l: anchor, v: 0, up: yang }); | |
| 143 | + prevShoulder = extreme; | |
| 144 | + anchor = extreme; extreme = p; dir = -1; | |
| 145 | + } | |
| 146 | + } else { | |
| 147 | + if (p < extreme) extreme = p; | |
| 148 | + if (p - extreme >= rev) { | |
| 149 | + if (prevWaist != null && extreme < prevWaist) yang = false; | |
| 150 | + out.push({ t: b.t, o: anchor, c: extreme, h: anchor, l: extreme, v: 0, up: yang }); | |
| 151 | + prevWaist = extreme; | |
| 152 | + anchor = extreme; extreme = p; dir = 1; | |
| 153 | + } | |
| 154 | + } | |
| 155 | + } | |
| 156 | + if (dir !== 0 && extreme !== anchor) { | |
| 157 | + out.push({ t: bars[bars.length - 1].t, o: anchor, c: extreme, | |
| 158 | + h: Math.max(anchor, extreme), l: Math.min(anchor, extreme), v: 0, up: yang }); | |
| 159 | + } | |
| 160 | + return out; | |
| 161 | + } | |
| 162 | + | |
| 163 | + /* Point & Figure : colonnes de X (hausse) / O (baisse), renversement N boîtes. | |
| 164 | + * item = {type:'X'|'O', lo, n, box} → n boîtes empilées à partir de lo. */ | |
| 165 | + function pnf(bars, opts) { | |
| 166 | + const box = (opts && opts.box) || autoBrick(bars, 1); | |
| 167 | + const revN = (opts && opts.reversal) || 3; | |
| 168 | + const cols = []; | |
| 169 | + if (!bars.length || !(box > 0)) return cols; | |
| 170 | + const fb = p => Math.floor(p / box + 1e-9); | |
| 171 | + let dir = 0, top = fb(bars[0].c), bot = top; | |
| 172 | + const pushCol = (t, type) => { | |
| 173 | + cols.push({ | |
| 174 | + t, type, lo: bot * box, n: top - bot + 1, box, | |
| 175 | + o: type === 'X' ? bot * box : (top + 1) * box, | |
| 176 | + c: type === 'X' ? (top + 1) * box : bot * box, | |
| 177 | + h: (top + 1) * box, l: bot * box, v: 0, up: type === 'X', | |
| 178 | + }); | |
| 179 | + }; | |
| 180 | + for (const b of bars) { | |
| 181 | + const hiB = fb(b.h), loB = fb(b.l); | |
| 182 | + if (dir === 0) { | |
| 183 | + if (hiB > top) { dir = 1; top = hiB; } | |
| 184 | + else if (loB < bot) { dir = -1; bot = loB; } | |
| 185 | + continue; | |
| 186 | + } | |
| 187 | + if (dir > 0) { | |
| 188 | + if (hiB > top) top = hiB; | |
| 189 | + else if (top - loB >= revN) { | |
| 190 | + pushCol(b.t, 'X'); | |
| 191 | + const newTop = top - 1; | |
| 192 | + bot = loB; top = newTop; dir = -1; | |
| 193 | + } | |
| 194 | + } else { | |
| 195 | + if (loB < bot) bot = loB; | |
| 196 | + else if (hiB - bot >= revN) { | |
| 197 | + pushCol(b.t, 'O'); | |
| 198 | + const newBot = bot + 1; | |
| 199 | + top = hiB; bot = newBot; dir = 1; | |
| 200 | + } | |
| 201 | + } | |
| 202 | + } | |
| 203 | + if (dir !== 0 && top >= bot) pushCol(bars[bars.length - 1].t, dir > 0 ? 'X' : 'O'); | |
| 204 | + return cols; | |
| 205 | + } | |
| 206 | + | |
| 207 | + HF.transforms = { heikinAshi, renko, lineBreak, rangeBars, kagi, pnf, autoBrick, niceRound }; | |
| 208 | +})(); | |
| 209 | ||