wickchart 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +404 -0
- package/package.json +57 -0
- package/src/core.js +1055 -0
- package/src/feeds.js +192 -0
- package/src/hab-chart.js +2621 -0
- package/src/hab-feed.js +255 -0
- package/types/core.d.ts +572 -0
- package/types/feeds.d.ts +58 -0
- package/types/hab-chart.d.ts +377 -0
- package/types/hab-feed.d.ts +27 -0
package/src/hab-feed.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/* ==========================================================================
|
|
2
|
+
* <hab-feed> — declarative data feeds for <hab-chart>.
|
|
3
|
+
*
|
|
4
|
+
* <script type="module" src="https://unpkg.com/hab-view/feed"></script>
|
|
5
|
+
*
|
|
6
|
+
* <hab-feed for="chart" binance="BTCUSDT" tf="1h"></hab-feed>
|
|
7
|
+
* <hab-chart id="chart" indicators="sma:20 volume"></hab-chart>
|
|
8
|
+
*
|
|
9
|
+
* A fully live chart with zero JavaScript written. Sources:
|
|
10
|
+
* binance="SYMBOL" live via WebSocket (REST klines + backfill; falls back
|
|
11
|
+
* to REST polling, then to a synthetic stream when the
|
|
12
|
+
* network/region blocks Binance)
|
|
13
|
+
* demo="KEY" deterministic offline synthetic feed (BTC/ETH/SOL/DEMO
|
|
14
|
+
* base prices; any other key seeds a fresh series)
|
|
15
|
+
* url="ENDPOINT" generic REST JSON array of bars; optional poll="SECONDS"
|
|
16
|
+
*
|
|
17
|
+
* Attributes: for (chart id; auto-pairs with the first chart otherwise),
|
|
18
|
+
* tf (1m…1w), limit (initial bars, default 500), live="false" to disable
|
|
19
|
+
* streaming. Status is reflected in the `status` attribute and via
|
|
20
|
+
* `hab-feed:status` events (loading / live / polling / fallback / loaded /
|
|
21
|
+
* waiting / idle). `hab-feed:fallback` fires when a live source degrades.
|
|
22
|
+
* ========================================================================== */
|
|
23
|
+
|
|
24
|
+
import './hab-chart.js';
|
|
25
|
+
import {
|
|
26
|
+
genSynthetic,
|
|
27
|
+
makeSynthStream,
|
|
28
|
+
fetchBinanceKlines,
|
|
29
|
+
openBinanceSocket,
|
|
30
|
+
tfToSeconds,
|
|
31
|
+
BASE_PRICES,
|
|
32
|
+
} from './feeds.js';
|
|
33
|
+
|
|
34
|
+
const LIVE_TICK_MS = 650;
|
|
35
|
+
|
|
36
|
+
const HTMLElementBase = typeof HTMLElement !== 'undefined' ? HTMLElement : class {};
|
|
37
|
+
|
|
38
|
+
class HabFeed extends HTMLElementBase {
|
|
39
|
+
static get observedAttributes() {
|
|
40
|
+
return ['for', 'binance', 'demo', 'url', 'tf', 'limit', 'poll', 'live'];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
constructor() {
|
|
44
|
+
super();
|
|
45
|
+
this._gen = 0; // generation token: stale async callbacks no-op
|
|
46
|
+
this._closers = [];
|
|
47
|
+
this._timer = 0;
|
|
48
|
+
this._observer = null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
connectedCallback() {
|
|
52
|
+
this._scheduleRestart();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
disconnectedCallback() {
|
|
56
|
+
this._teardown();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
attributeChangedCallback(name, oldVal, newVal) {
|
|
60
|
+
if (oldVal !== newVal) this._scheduleRestart();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_scheduleRestart() {
|
|
64
|
+
clearTimeout(this._timer);
|
|
65
|
+
this._timer = setTimeout(() => {
|
|
66
|
+
this._timer = 0;
|
|
67
|
+
if (this.isConnected) this._restart();
|
|
68
|
+
}, 0);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
_teardown() {
|
|
72
|
+
this._gen++;
|
|
73
|
+
clearTimeout(this._timer);
|
|
74
|
+
this._timer = 0;
|
|
75
|
+
for (const close of this._closers.splice(0)) {
|
|
76
|
+
try {
|
|
77
|
+
close();
|
|
78
|
+
} catch (_) {}
|
|
79
|
+
}
|
|
80
|
+
if (this._observer) {
|
|
81
|
+
this._observer.disconnect();
|
|
82
|
+
this._observer = null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
_setStatus(status, detail) {
|
|
87
|
+
if (!this.isConnected) return;
|
|
88
|
+
this.setAttribute('status', status);
|
|
89
|
+
this.dispatchEvent(new CustomEvent('hab-feed:status', { detail: { status, ...detail } }));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Resolve the target chart (by `for` id, else the first <hab-chart>). */
|
|
93
|
+
_resolveChart() {
|
|
94
|
+
const id = this.getAttribute('for');
|
|
95
|
+
if (id) {
|
|
96
|
+
const el = document.getElementById(id);
|
|
97
|
+
return el && el.tagName === 'HAB-CHART' ? el : null;
|
|
98
|
+
}
|
|
99
|
+
return document.querySelector('hab-chart');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
_restart() {
|
|
103
|
+
this._teardown();
|
|
104
|
+
let chart = this._resolveChart();
|
|
105
|
+
if (!chart || typeof chart.setData !== 'function') {
|
|
106
|
+
// chart not in the DOM yet (or not upgraded) — watch for it
|
|
107
|
+
this._setStatus('waiting');
|
|
108
|
+
customElements.whenDefined('hab-chart').then(() => {
|
|
109
|
+
if (!this.isConnected) return;
|
|
110
|
+
this._observer = this._observer || new MutationObserver(() => {
|
|
111
|
+
const c = this._resolveChart();
|
|
112
|
+
if (c && typeof c.setData === 'function') {
|
|
113
|
+
this._observer.disconnect();
|
|
114
|
+
this._observer = null;
|
|
115
|
+
this._scheduleRestart();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
this._observer.observe(document.documentElement, { childList: true, subtree: true });
|
|
119
|
+
});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (!chart.hasAttribute('label')) {
|
|
123
|
+
const sym = this.getAttribute('binance') || this.getAttribute('demo');
|
|
124
|
+
if (sym) chart.setAttribute('label', `${String(sym).toUpperCase()} · ${this.getAttribute('tf') || '1h'}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const gen = this._gen;
|
|
128
|
+
const live = this.getAttribute('live') !== 'false';
|
|
129
|
+
const tfId = (this.getAttribute('tf') || '1h').toLowerCase();
|
|
130
|
+
const limit = Math.max(10, Math.min(5000, parseInt(this.getAttribute('limit') || '500', 10) || 500));
|
|
131
|
+
const sym = this.getAttribute('binance');
|
|
132
|
+
const url = this.getAttribute('url');
|
|
133
|
+
const demo = this.getAttribute('demo');
|
|
134
|
+
|
|
135
|
+
if (sym) this._binance(gen, chart, String(sym).toUpperCase(), tfId, limit, live);
|
|
136
|
+
else if (url) this._rest(gen, chart, url, limit, live);
|
|
137
|
+
else if (demo != null) {
|
|
138
|
+
this._synthetic(gen, chart, demo === '' ? 'DEMO' : demo, tfId, limit, live);
|
|
139
|
+
} else this._setStatus('idle');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* ---------------- synthetic source ---------------- */
|
|
143
|
+
|
|
144
|
+
_synthetic(gen, chart, key, tfId, limit, live, status = 'live') {
|
|
145
|
+
const sec = tfToSeconds(tfId);
|
|
146
|
+
const base = BASE_PRICES[key.toUpperCase()] || 100;
|
|
147
|
+
const histLen = Math.max(limit * 5, 3000);
|
|
148
|
+
const hist = genSynthetic(`${key}:${tfId}`, sec, histLen, base);
|
|
149
|
+
chart.onloadmore = (fromTime) => hist.filter((b) => b.time < fromTime).slice(-limit);
|
|
150
|
+
chart.setData(hist.slice(-limit));
|
|
151
|
+
this._setStatus(status);
|
|
152
|
+
if (!live) return;
|
|
153
|
+
const d = chart.data;
|
|
154
|
+
const next = makeSynthStream(sec, d.length ? d[d.length - 1].close : base);
|
|
155
|
+
const timer = setInterval(() => {
|
|
156
|
+
if (this._gen !== gen || !this.isConnected) return;
|
|
157
|
+
chart.update(next());
|
|
158
|
+
}, LIVE_TICK_MS);
|
|
159
|
+
this._closers.push(() => clearInterval(timer));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/* ---------------- Binance source ---------------- */
|
|
163
|
+
|
|
164
|
+
async _binance(gen, chart, sym, tfId, limit, live) {
|
|
165
|
+
this._setStatus('loading');
|
|
166
|
+
try {
|
|
167
|
+
const bars = await fetchBinanceKlines(sym, tfId, limit);
|
|
168
|
+
if (this._gen !== gen || !this.isConnected) return;
|
|
169
|
+
chart.setData(bars);
|
|
170
|
+
chart.onloadmore = (fromTime) => fetchBinanceKlines(sym, tfId, limit, fromTime);
|
|
171
|
+
if (!live) {
|
|
172
|
+
this._setStatus('loaded');
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const ws = openBinanceSocket(
|
|
176
|
+
sym,
|
|
177
|
+
tfId,
|
|
178
|
+
(bar) => {
|
|
179
|
+
if (this._gen !== gen || !this.isConnected) return;
|
|
180
|
+
this._setStatus('live');
|
|
181
|
+
chart.update(bar);
|
|
182
|
+
},
|
|
183
|
+
() => {
|
|
184
|
+
if (this._gen !== gen || !this.isConnected) return;
|
|
185
|
+
this._pollBinance(gen, chart, sym, tfId);
|
|
186
|
+
}
|
|
187
|
+
);
|
|
188
|
+
this._closers.push(() => ws.close());
|
|
189
|
+
} catch (err) {
|
|
190
|
+
if (this._gen !== gen || !this.isConnected) return;
|
|
191
|
+
this._degrade(gen, chart, sym, tfId, limit, live, err);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
_pollBinance(gen, chart, sym, tfId) {
|
|
196
|
+
this._setStatus('polling');
|
|
197
|
+
const timer = setInterval(async () => {
|
|
198
|
+
if (this._gen !== gen || !this.isConnected) return;
|
|
199
|
+
try {
|
|
200
|
+
const bars = await fetchBinanceKlines(sym, tfId, 2);
|
|
201
|
+
for (const b of bars) chart.update(b);
|
|
202
|
+
} catch (_) {
|
|
203
|
+
clearInterval(timer);
|
|
204
|
+
this._degrade(gen, chart, sym, tfId, 500, true, new Error('poll failed'));
|
|
205
|
+
}
|
|
206
|
+
}, 10000);
|
|
207
|
+
this._closers.push(() => clearInterval(timer));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
_degrade(gen, chart, sym, tfId, limit, live, err) {
|
|
211
|
+
this.dispatchEvent(
|
|
212
|
+
new CustomEvent('hab-feed:fallback', { detail: { reason: err && err.message } })
|
|
213
|
+
);
|
|
214
|
+
this._synthetic(gen, chart, sym, tfId, limit, live, 'fallback');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/* ---------------- generic REST source ---------------- */
|
|
218
|
+
|
|
219
|
+
async _rest(gen, chart, url, limit, live) {
|
|
220
|
+
this._setStatus('loading');
|
|
221
|
+
const pull = async () => {
|
|
222
|
+
const res = await fetch(url);
|
|
223
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
224
|
+
const body = await res.json();
|
|
225
|
+
return Array.isArray(body) ? body : body.bars;
|
|
226
|
+
};
|
|
227
|
+
try {
|
|
228
|
+
const bars = await pull();
|
|
229
|
+
if (this._gen !== gen || !this.isConnected) return;
|
|
230
|
+
chart.setData(bars.slice(-limit));
|
|
231
|
+
this._setStatus(live ? 'loaded' : 'loaded');
|
|
232
|
+
if (!live) return;
|
|
233
|
+
const pollSec = Math.max(1, parseInt(this.getAttribute('poll') || '0', 10) || 0);
|
|
234
|
+
if (!pollSec) return;
|
|
235
|
+
const timer = setInterval(async () => {
|
|
236
|
+
if (this._gen !== gen || !this.isConnected) return;
|
|
237
|
+
try {
|
|
238
|
+
const fresh = await pull();
|
|
239
|
+
for (const b of fresh.slice(-3)) chart.update(b);
|
|
240
|
+
this._setStatus('polling');
|
|
241
|
+
} catch (_) {}
|
|
242
|
+
}, pollSec * 1000);
|
|
243
|
+
this._closers.push(() => clearInterval(timer));
|
|
244
|
+
} catch (err) {
|
|
245
|
+
if (this._gen !== gen || !this.isConnected) return;
|
|
246
|
+
this._setStatus('error', { message: err && err.message });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (typeof customElements !== 'undefined' && !customElements.get('hab-feed')) {
|
|
252
|
+
customElements.define('hab-feed', HabFeed);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export default HabFeed;
|