@livo-build/charts 0.2.2 → 0.2.3
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 +116 -19
- package/dist/core/chart.d.ts +44 -2
- package/dist/core/chart.js +123 -11
- package/dist/core/feed.js +27 -6
- package/dist/core/indicators.d.ts +36 -0
- package/dist/core/indicators.js +119 -0
- package/dist/core/ohlc.d.ts +10 -0
- package/dist/core/ohlc.js +30 -0
- package/dist/core/polymarket.d.ts +44 -0
- package/dist/core/polymarket.js +92 -0
- package/dist/core/renderer.d.ts +19 -4
- package/dist/core/renderer.js +217 -34
- package/dist/core/signals.d.ts +63 -0
- package/dist/core/signals.js +234 -0
- package/dist/core/types.d.ts +31 -8
- package/dist/index.d.ts +9 -4
- package/dist/index.js +5 -3
- package/dist/react/HyperliquidChart.d.ts +4 -2
- package/dist/react/HyperliquidChart.js +11 -3
- package/dist/react/PolymarketChart.d.ts +40 -0
- package/dist/react/PolymarketChart.js +95 -0
- package/dist/react/PriceChart.d.ts +18 -3
- package/dist/react/PriceChart.js +16 -4
- package/dist/react/SignalsChart.d.ts +37 -0
- package/dist/react/SignalsChart.js +95 -0
- package/dist/react/ui.d.ts +13 -2
- package/dist/react/ui.js +46 -0
- package/dist/react.d.ts +4 -0
- package/dist/react.js +2 -0
- package/package.json +2 -2
package/dist/core/renderer.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import { formatAxisValue, formatTime } from "./format";
|
|
2
|
-
import { computeIndicator, sourceValues, bollingerBands, INDICATOR_PALETTE, rsi, macd } from "./indicators";
|
|
1
|
+
import { formatAxisValue, formatTime, formatValue } from "./format";
|
|
2
|
+
import { computeIndicator, sourceValues, bollingerBands, INDICATOR_PALETTE, rsi, macd, stochastic, atr, volumeProfile } from "./indicators";
|
|
3
|
+
import { heikinAshi } from "./ohlc";
|
|
4
|
+
/** Standard Fibonacci retracement ratios (0 and 1 are the anchors themselves). */
|
|
5
|
+
const FIB_LEVELS = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1];
|
|
3
6
|
/**
|
|
4
7
|
* Width of one candle slot (px). By default the slot is capped at `maxBarWidth` and the
|
|
5
8
|
* series is right-anchored — sparse data stays tight in the corner. With `fitContent`,
|
|
@@ -18,13 +21,17 @@ export function withAlpha(color, alpha) {
|
|
|
18
21
|
const n = parseInt(m[1], 16);
|
|
19
22
|
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
|
|
20
23
|
}
|
|
21
|
-
/** The visible candle window (which slice is on screen and the per-candle slot width).
|
|
24
|
+
/** The visible candle window (which slice is on screen and the per-candle slot width).
|
|
25
|
+
* A negative `view.offset` scrolls past the newest candle into the future: `end` exceeds
|
|
26
|
+
* the data length and `rightGap` counts the empty slots between the newest candle and the
|
|
27
|
+
* right edge (so the user can pull the current price left of the edge / toward center). */
|
|
22
28
|
export function windowOf(candles, view, plotW, maxBarWidth, fitContent = false) {
|
|
23
29
|
const count = Math.min(view.count, candles.length);
|
|
24
|
-
const end = candles.length - view.offset;
|
|
30
|
+
const end = candles.length - view.offset; // virtual right edge (may exceed length when offset < 0)
|
|
25
31
|
const start = Math.max(0, end - count);
|
|
26
|
-
const vis = candles.slice(start, end);
|
|
27
|
-
|
|
32
|
+
const vis = candles.slice(start, Math.min(candles.length, end));
|
|
33
|
+
const rightGap = Math.max(0, end - candles.length); // empty slots to the right of the newest candle
|
|
34
|
+
return { start, end, vis, n: vis.length, count, rightGap, cw: slotWidth(plotW, count, maxBarWidth, fitContent) };
|
|
28
35
|
}
|
|
29
36
|
/**
|
|
30
37
|
* Price ↔ pixel mapping for the price pane, shared by the renderer and the drawing-tool
|
|
@@ -38,7 +45,9 @@ export function priceScale(vis, yZoom, logScale, top, priceH) {
|
|
|
38
45
|
hi = Math.max(hi, c.h);
|
|
39
46
|
}
|
|
40
47
|
const log = !!logScale && lo > 0;
|
|
41
|
-
|
|
48
|
+
// In log mode, clamp the forward input to a tiny positive epsilon so an arbitrary drawing
|
|
49
|
+
// anchor or crosshair price ≤ 0 yields a finite (off-screen) y instead of NaN/-Infinity.
|
|
50
|
+
const fwd = (v) => (log ? Math.log10(v > 0 ? v : 1e-12) : v);
|
|
42
51
|
const inv = (s) => (log ? 10 ** s : s);
|
|
43
52
|
let sLo = fwd(lo);
|
|
44
53
|
let sHi = fwd(hi);
|
|
@@ -51,9 +60,10 @@ export function priceScale(vis, yZoom, logScale, top, priceH) {
|
|
|
51
60
|
const priceOfY = (y) => inv(sLo + sRng * (1 - (y - top) / priceH));
|
|
52
61
|
return { yOfPrice, priceOfY, fwd, inv, sLo, sRng, lo, hi };
|
|
53
62
|
}
|
|
54
|
-
/** Time ↔ pixel mapping for the visible window (candle index and arbitrary unix-second time).
|
|
55
|
-
|
|
56
|
-
|
|
63
|
+
/** Time ↔ pixel mapping for the visible window (candle index and arbitrary unix-second time).
|
|
64
|
+
* `rightGap` (empty future slots, see windowOf) shifts the candles left of the right edge. */
|
|
65
|
+
export function timeScale(vis, n, cw, plotW, interval, rightGap = 0) {
|
|
66
|
+
const xOf = (j) => plotW - (rightGap + n - 1 - j) * cw - cw / 2;
|
|
57
67
|
const t0 = n ? vis[0].time : 0;
|
|
58
68
|
const iv = interval || 1;
|
|
59
69
|
const xOfTime = (t) => xOf((t - t0) / iv);
|
|
@@ -62,14 +72,17 @@ export function timeScale(vis, n, cw, plotW, interval) {
|
|
|
62
72
|
}
|
|
63
73
|
/** Compute the {@link Projection} for interaction/hit-testing without rendering. */
|
|
64
74
|
export function computeProjection(input) {
|
|
65
|
-
const {
|
|
75
|
+
const { view, pads, width, maxBarWidth, volH, height, yZoom, interval } = input;
|
|
76
|
+
// The visible price axis is the Heikin-Ashi range in `heikin` mode, so map against it.
|
|
77
|
+
// Prefer the controller's precomputed series; derive only for standalone callers.
|
|
78
|
+
const candles = input.priceCandles ?? (input.type === "heikin" ? heikinAshi(input.candles) : input.candles);
|
|
66
79
|
if (!candles.length)
|
|
67
80
|
return null;
|
|
68
81
|
const plotW = width - pads.right;
|
|
69
82
|
const panesH = (input.oscillators ?? []).map((o) => Math.max(24, o.height ?? 84)).reduce((a, b) => a + b, 0);
|
|
70
83
|
const priceH = height - pads.bottom - pads.top - volH - panesH;
|
|
71
|
-
const { vis, n, cw } = windowOf(candles, view, plotW, maxBarWidth, input.fitContent);
|
|
72
|
-
const ts = timeScale(vis, n, cw, plotW, interval);
|
|
84
|
+
const { vis, n, cw, rightGap } = windowOf(candles, view, plotW, maxBarWidth, input.fitContent);
|
|
85
|
+
const ts = timeScale(vis, n, cw, plotW, interval, rightGap);
|
|
73
86
|
const ps = priceScale(vis, yZoom, !!input.logScale, pads.top, priceH);
|
|
74
87
|
return {
|
|
75
88
|
xOfTime: ts.xOfTime,
|
|
@@ -127,21 +140,33 @@ export function draw(input) {
|
|
|
127
140
|
}
|
|
128
141
|
ctx.font = input.axisFont ?? "10px ui-monospace, monospace";
|
|
129
142
|
ctx.textBaseline = "middle";
|
|
130
|
-
|
|
143
|
+
let fmtTime = input.timeFormat ?? formatTime;
|
|
131
144
|
// Snap 1px strokes to the pixel grid so gridlines/axes render crisp, not blurred across 2px.
|
|
132
145
|
const crisp = (v) => Math.round(v) + 0.5;
|
|
133
146
|
if (!candles.length) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
147
|
+
const msg = input.emptyText ?? "no priced trades yet";
|
|
148
|
+
if (msg) {
|
|
149
|
+
ctx.fillStyle = theme.axis;
|
|
150
|
+
ctx.textAlign = "center";
|
|
151
|
+
ctx.fillText(msg, plotW / 2, H / 2);
|
|
152
|
+
}
|
|
137
153
|
return null;
|
|
138
154
|
}
|
|
139
155
|
// Slot width fills the plot by default (`fitContent`, on by default in the wrappers and
|
|
140
156
|
// the core Chart) so sparse data spreads out instead of bunching on the right; the candle
|
|
141
157
|
// BODY is capped separately (`maxBodyWidth`) below. fitContent:false caps the slot at
|
|
142
158
|
// `maxBarWidth` and right-anchors (the tight, TradingView-style look).
|
|
143
|
-
|
|
144
|
-
|
|
159
|
+
// The price series + axis use Heikin-Ashi candles in `heikin` mode (same times/volumes,
|
|
160
|
+
// smoothed OHLC); indicators/oscillators still read the RAW closes (`candles`) below.
|
|
161
|
+
// The controller precomputes this on data/type change (it's O(n)); derive only standalone.
|
|
162
|
+
const priceCandles = input.priceCandles ?? (type === "heikin" ? heikinAshi(candles) : candles);
|
|
163
|
+
const { start, vis, n, cw, rightGap } = windowOf(priceCandles, view, plotW, maxBarWidth, input.fitContent);
|
|
164
|
+
const { xOf, xOfTime } = timeScale(vis, n, cw, plotW, interval, rightGap);
|
|
165
|
+
// When sub-daily candles span more than a day, time-only labels read as out-of-order across
|
|
166
|
+
// midnight — include the date (M/D h:m) so the axis stays chronological & legible.
|
|
167
|
+
if (!input.timeFormat && n > 1 && vis[n - 1].time - vis[0].time > 86400 && interval < 86400) {
|
|
168
|
+
fmtTime = (t) => formatTime(t, 3600);
|
|
169
|
+
}
|
|
145
170
|
let vmax = 0;
|
|
146
171
|
for (const c of vis)
|
|
147
172
|
vmax = Math.max(vmax, c.vol);
|
|
@@ -241,6 +266,51 @@ export function draw(input) {
|
|
|
241
266
|
ctx.stroke();
|
|
242
267
|
ctx.lineWidth = 1;
|
|
243
268
|
}
|
|
269
|
+
else if (type === "baseline") {
|
|
270
|
+
// Two-tone area around a reference price: above the baseline is "up", below is "down".
|
|
271
|
+
const priceBot = pads.top + priceH;
|
|
272
|
+
const baseP = input.baselinePrice ?? vis[0].c;
|
|
273
|
+
const by = Math.max(pads.top, Math.min(priceBot, yOf(baseP)));
|
|
274
|
+
const pts = [];
|
|
275
|
+
for (let j = 0; j < n; j++)
|
|
276
|
+
pts.push({ x: xOf(j), y: yOf(vis[j].c) });
|
|
277
|
+
const area = (color, clipTop, clipH) => {
|
|
278
|
+
if (clipH <= 0)
|
|
279
|
+
return;
|
|
280
|
+
ctx.save();
|
|
281
|
+
ctx.beginPath();
|
|
282
|
+
ctx.rect(0, clipTop, plotW, clipH);
|
|
283
|
+
ctx.clip();
|
|
284
|
+
// faint filled body…
|
|
285
|
+
ctx.fillStyle = withAlpha(color, 0.16);
|
|
286
|
+
ctx.beginPath();
|
|
287
|
+
ctx.moveTo(pts[0].x, by);
|
|
288
|
+
for (const p of pts)
|
|
289
|
+
ctx.lineTo(p.x, p.y);
|
|
290
|
+
ctx.lineTo(pts[n - 1].x, by);
|
|
291
|
+
ctx.closePath();
|
|
292
|
+
ctx.fill();
|
|
293
|
+
// …with a solid line on top (clipped to this side of the baseline)
|
|
294
|
+
ctx.strokeStyle = color;
|
|
295
|
+
ctx.lineWidth = 2;
|
|
296
|
+
ctx.lineJoin = "round";
|
|
297
|
+
ctx.beginPath();
|
|
298
|
+
for (let j = 0; j < n; j++)
|
|
299
|
+
(j ? ctx.lineTo(pts[j].x, pts[j].y) : ctx.moveTo(pts[j].x, pts[j].y));
|
|
300
|
+
ctx.stroke();
|
|
301
|
+
ctx.restore();
|
|
302
|
+
};
|
|
303
|
+
area(theme.up, pads.top, by - pads.top);
|
|
304
|
+
area(theme.down, by, priceBot - by);
|
|
305
|
+
ctx.strokeStyle = theme.grid;
|
|
306
|
+
ctx.setLineDash([4, 4]);
|
|
307
|
+
ctx.beginPath();
|
|
308
|
+
ctx.moveTo(0, crisp(by));
|
|
309
|
+
ctx.lineTo(plotW, crisp(by));
|
|
310
|
+
ctx.stroke();
|
|
311
|
+
ctx.setLineDash([]);
|
|
312
|
+
ctx.lineWidth = 1;
|
|
313
|
+
}
|
|
244
314
|
else {
|
|
245
315
|
for (let j = 0; j < n; j++) {
|
|
246
316
|
const c = vis[j];
|
|
@@ -257,6 +327,25 @@ export function draw(input) {
|
|
|
257
327
|
ctx.fillRect(x - bw / 2, Math.min(yo, yc), bw, Math.max(1, Math.abs(yc - yo)));
|
|
258
328
|
}
|
|
259
329
|
}
|
|
330
|
+
// volume-by-price histogram — translucent bars grown leftward from the right gutter,
|
|
331
|
+
// peaked at the Point of Control. Computed over the visible window so it tracks zoom/pan.
|
|
332
|
+
if (input.volumeProfile) {
|
|
333
|
+
const cfg = typeof input.volumeProfile === "object" ? input.volumeProfile : {};
|
|
334
|
+
const vp = volumeProfile(vis, cfg.buckets ?? 24);
|
|
335
|
+
const maxW = (cfg.width ?? 0.16) * plotW;
|
|
336
|
+
const color = cfg.color ?? withAlpha(theme.axis, 0.22);
|
|
337
|
+
for (const b of vp.buckets) {
|
|
338
|
+
if (b.vol <= 0)
|
|
339
|
+
continue;
|
|
340
|
+
const yTop = yOf(b.hi);
|
|
341
|
+
const yBot = yOf(b.lo);
|
|
342
|
+
const bh = Math.max(1, yBot - yTop - 1);
|
|
343
|
+
const wpx = (b.vol / vp.maxVol) * maxW;
|
|
344
|
+
// the Point-of-Control band gets a slightly stronger tint
|
|
345
|
+
ctx.fillStyle = Math.abs((b.lo + b.hi) / 2 - vp.poc) < (b.hi - b.lo) / 2 ? withAlpha(theme.line, 0.32) : color;
|
|
346
|
+
ctx.fillRect(plotW - wpx, yTop, wpx, bh);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
260
349
|
// indicator overlays (moving averages) — drawn from precomputed value-series when
|
|
261
350
|
// supplied (the Chart controller precomputes them on data change), else computed
|
|
262
351
|
// here from the indicator config for pure/standalone callers. Series align to the
|
|
@@ -316,11 +405,13 @@ export function draw(input) {
|
|
|
316
405
|
ctx.fillStyle = theme.tagText;
|
|
317
406
|
ctx.textAlign = "left";
|
|
318
407
|
ctx.fillText(fmtPrice(last.c), plotW + 6, ly);
|
|
319
|
-
// crosshair + axis labels
|
|
320
|
-
|
|
408
|
+
// crosshair + axis labels. `active` is the candle returned to onCrosshair — always the
|
|
409
|
+
// RAW candle (real OHLC), even in heikin mode where the drawn body is smoothed.
|
|
410
|
+
let active = candles[start + n - 1];
|
|
321
411
|
if (hover && hover.x < plotW && hover.y > 0 && hover.y < H - pads.bottom) {
|
|
322
|
-
|
|
323
|
-
|
|
412
|
+
// invert xOf (which now accounts for rightGap whitespace); clamp into the data range.
|
|
413
|
+
const idx = Math.max(0, Math.min(n - 1, Math.round((hover.x - xOf(0)) / cw)));
|
|
414
|
+
active = candles[start + idx];
|
|
324
415
|
const cx = xOf(idx);
|
|
325
416
|
ctx.strokeStyle = theme.crosshair;
|
|
326
417
|
ctx.setLineDash([3, 3]);
|
|
@@ -355,23 +446,61 @@ function drawDrawing(ctx, d, xOfTime, yOfPrice, plotW, theme, selected) {
|
|
|
355
446
|
ctx.strokeStyle = color;
|
|
356
447
|
ctx.lineWidth = selected ? 2.5 : 1.5;
|
|
357
448
|
const pts = [];
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
449
|
+
if (d.type === "fib" && d.b) {
|
|
450
|
+
// Retracement levels between the two anchor prices; lines extend to the right edge.
|
|
451
|
+
const ax = xOfTime(d.a.time);
|
|
452
|
+
const bx = xOfTime(d.b.time);
|
|
453
|
+
const xL = Math.min(ax, bx);
|
|
454
|
+
const p0 = d.a.price;
|
|
455
|
+
const p1 = d.b.price;
|
|
456
|
+
ctx.textAlign = "left";
|
|
457
|
+
for (const r of FIB_LEVELS) {
|
|
458
|
+
const price = p0 + (p1 - p0) * r;
|
|
459
|
+
const y = yOfPrice(price);
|
|
460
|
+
ctx.strokeStyle = withAlpha(color, r === 0 || r === 1 ? 0.9 : 0.55);
|
|
461
|
+
ctx.beginPath();
|
|
462
|
+
ctx.moveTo(xL, y);
|
|
463
|
+
ctx.lineTo(plotW, y);
|
|
464
|
+
ctx.stroke();
|
|
465
|
+
ctx.fillStyle = withAlpha(color, 0.95);
|
|
466
|
+
ctx.fillText(`${(r * 100).toFixed(1)}% ${formatValue(price)}`, xL + 4, y - 6);
|
|
467
|
+
}
|
|
468
|
+
pts.push({ x: ax, y: yOfPrice(p0) }, { x: bx, y: yOfPrice(p1) });
|
|
364
469
|
}
|
|
365
|
-
else if (d.b) {
|
|
470
|
+
else if (d.type === "rect" && d.b) {
|
|
366
471
|
const ax = xOfTime(d.a.time);
|
|
367
472
|
const ay = yOfPrice(d.a.price);
|
|
368
473
|
const bx = xOfTime(d.b.time);
|
|
369
474
|
const by = yOfPrice(d.b.price);
|
|
370
|
-
|
|
371
|
-
|
|
475
|
+
const x = Math.min(ax, bx);
|
|
476
|
+
const y = Math.min(ay, by);
|
|
477
|
+
const w = Math.abs(bx - ax);
|
|
478
|
+
const h = Math.abs(by - ay);
|
|
479
|
+
ctx.fillStyle = withAlpha(color, 0.12);
|
|
480
|
+
ctx.fillRect(x, y, w, h);
|
|
481
|
+
ctx.strokeStyle = color;
|
|
482
|
+
ctx.strokeRect(x, y, w, h);
|
|
372
483
|
pts.push({ x: ax, y: ay }, { x: bx, y: by });
|
|
373
484
|
}
|
|
374
|
-
|
|
485
|
+
else {
|
|
486
|
+
ctx.beginPath();
|
|
487
|
+
if (d.type === "hline") {
|
|
488
|
+
const y = yOfPrice(d.a.price);
|
|
489
|
+
ctx.moveTo(0, y);
|
|
490
|
+
ctx.lineTo(plotW, y);
|
|
491
|
+
pts.push({ x: plotW / 2, y });
|
|
492
|
+
}
|
|
493
|
+
else if (d.b) {
|
|
494
|
+
const ax = xOfTime(d.a.time);
|
|
495
|
+
const ay = yOfPrice(d.a.price);
|
|
496
|
+
const bx = xOfTime(d.b.time);
|
|
497
|
+
const by = yOfPrice(d.b.price);
|
|
498
|
+
ctx.moveTo(ax, ay);
|
|
499
|
+
ctx.lineTo(bx, by);
|
|
500
|
+
pts.push({ x: ax, y: ay }, { x: bx, y: by });
|
|
501
|
+
}
|
|
502
|
+
ctx.stroke();
|
|
503
|
+
}
|
|
375
504
|
if (selected) {
|
|
376
505
|
ctx.fillStyle = color;
|
|
377
506
|
for (const p of pts)
|
|
@@ -380,7 +509,7 @@ function drawDrawing(ctx, d, xOfTime, yOfPrice, plotW, theme, selected) {
|
|
|
380
509
|
ctx.lineWidth = 1;
|
|
381
510
|
}
|
|
382
511
|
/** Default oscillator colors. */
|
|
383
|
-
const OSC_COLORS = { rsi: "#ab47bc", macdLine: "#2962ff", macdSignal: "#ff9800" };
|
|
512
|
+
const OSC_COLORS = { rsi: "#ab47bc", macdLine: "#2962ff", macdSignal: "#ff9800", stochK: "#2962ff", stochD: "#ff9800", atr: "#26a69a" };
|
|
384
513
|
/** Last non-null value of a series (for the pane's inline readout). */
|
|
385
514
|
function lastDefined(s) {
|
|
386
515
|
for (let i = s.length - 1; i >= 0; i--)
|
|
@@ -453,6 +582,60 @@ function drawPane(ctx, osc, paneH, top, env) {
|
|
|
453
582
|
ctx.fillText(`RSI ${period}${lv != null ? " " + lv.toFixed(1) : ""}`, 6, top + padY);
|
|
454
583
|
return;
|
|
455
584
|
}
|
|
585
|
+
if (osc.type === "stoch") {
|
|
586
|
+
const kP = osc.period ?? 14;
|
|
587
|
+
const dP = osc.dPeriod ?? 3;
|
|
588
|
+
const sm = osc.smooth ?? 1;
|
|
589
|
+
const { k, d } = stochastic(candles, kP, dP, sm);
|
|
590
|
+
const yOf = (v) => innerTop + innerH * (1 - v / 100);
|
|
591
|
+
// 80 / 20 guide rails
|
|
592
|
+
ctx.setLineDash([2, 3]);
|
|
593
|
+
for (const lvl of [80, 20]) {
|
|
594
|
+
const y = yOf(lvl);
|
|
595
|
+
ctx.strokeStyle = theme.grid;
|
|
596
|
+
ctx.beginPath();
|
|
597
|
+
ctx.moveTo(0, y);
|
|
598
|
+
ctx.lineTo(plotW, y);
|
|
599
|
+
ctx.stroke();
|
|
600
|
+
ctx.fillStyle = theme.axis;
|
|
601
|
+
ctx.fillText(String(lvl), plotW + 6, y);
|
|
602
|
+
}
|
|
603
|
+
ctx.setLineDash([]);
|
|
604
|
+
strokeSeries(ctx, k, env, yOf, osc.color ?? OSC_COLORS.stochK);
|
|
605
|
+
strokeSeries(ctx, d, env, yOf, OSC_COLORS.stochD);
|
|
606
|
+
const lv = lastDefined(k);
|
|
607
|
+
ctx.fillStyle = theme.axis;
|
|
608
|
+
ctx.fillText(`STOCH ${kP} ${dP}${lv != null ? " " + lv.toFixed(1) : ""}`, 6, top + padY);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (osc.type === "atr") {
|
|
612
|
+
const period = osc.period ?? 14;
|
|
613
|
+
const series = atr(candles, period);
|
|
614
|
+
// auto-scale to the visible ATR range (price units, always ≥ 0)
|
|
615
|
+
let lo = Infinity;
|
|
616
|
+
let hi = -Infinity;
|
|
617
|
+
for (let j = 0; j < n; j++) {
|
|
618
|
+
const v = series[start + j];
|
|
619
|
+
if (v != null) {
|
|
620
|
+
lo = Math.min(lo, v);
|
|
621
|
+
hi = Math.max(hi, v);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
if (!isFinite(lo)) {
|
|
625
|
+
lo = 0;
|
|
626
|
+
hi = 1;
|
|
627
|
+
}
|
|
628
|
+
const padR = (hi - lo) * 0.1 || hi * 0.1 || 1;
|
|
629
|
+
lo = Math.max(0, lo - padR);
|
|
630
|
+
hi += padR;
|
|
631
|
+
const rng = hi - lo || 1;
|
|
632
|
+
const yOf = (v) => innerTop + innerH * (1 - (v - lo) / rng);
|
|
633
|
+
strokeSeries(ctx, series, env, yOf, osc.color ?? OSC_COLORS.atr);
|
|
634
|
+
const lv = lastDefined(series);
|
|
635
|
+
ctx.fillStyle = theme.axis;
|
|
636
|
+
ctx.fillText(`ATR ${period}${lv != null ? " " + formatValue(lv) : ""}`, 6, top + padY);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
456
639
|
// MACD: histogram + macd line + signal line, symmetric around zero
|
|
457
640
|
const fast = osc.fast ?? 12;
|
|
458
641
|
const slow = osc.slow ?? 26;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Candle, Point } from "./types";
|
|
2
|
+
import type { ChartFeed } from "./feed";
|
|
3
|
+
/** The public Livo signals engine. */
|
|
4
|
+
export declare const DEFAULT_SIGNALS_URL = "https://signals.livo.build";
|
|
5
|
+
/** The fields of a Signal Radar market this adapter needs. `spark` is recent closes (oldest→newest). */
|
|
6
|
+
export interface SignalsMarket {
|
|
7
|
+
pair: string;
|
|
8
|
+
base: string;
|
|
9
|
+
pool: string;
|
|
10
|
+
price: number;
|
|
11
|
+
spark: number[];
|
|
12
|
+
}
|
|
13
|
+
/** Fetch one Signal Radar market (price + spark) from the engine snapshot, or null if absent. */
|
|
14
|
+
export declare function fetchSignalsMarket(token: string, url?: string): Promise<SignalsMarket | null>;
|
|
15
|
+
/**
|
|
16
|
+
* Fetch a page of indexed swaps for a pool from the engine's GraphQL API (`allSwaps`,
|
|
17
|
+
* newest-first), mapped to priced {@link Point}s. `pool` must be the 0x-prefixed address.
|
|
18
|
+
* Returns one point per swap (unpriceable swaps keep `p: 0` and are dropped by `buildOHLC`),
|
|
19
|
+
* so the caller can advance its offset by `points.length`.
|
|
20
|
+
*/
|
|
21
|
+
export declare function fetchSignalsSwaps(pool: string, opts?: {
|
|
22
|
+
url?: string;
|
|
23
|
+
first?: number;
|
|
24
|
+
offset?: number;
|
|
25
|
+
}): Promise<Point[]>;
|
|
26
|
+
/**
|
|
27
|
+
* Drop swaps whose price is a wild outlier — the engine occasionally mis-prices historical
|
|
28
|
+
* swaps (e.g. a thin pool's old trades priced at $2.5M for a $3 token). Uses the median of
|
|
29
|
+
* the newest swaps as a robust reference (recent prices are reliable) and rejects anything
|
|
30
|
+
* more than `factor`× off it. Input is newest-first; output preserves order.
|
|
31
|
+
*/
|
|
32
|
+
export declare function rejectPriceOutliers(pts: Point[], factor?: number): Point[];
|
|
33
|
+
/** Turn a `spark` (recent closes, oldest→newest) into flat candles ending at `nowSec`. */
|
|
34
|
+
export declare function sparkCandles(spark: number[], bucketSeconds: number, nowSec: number, lastClose?: number): Candle[];
|
|
35
|
+
export interface SignalsFeedOptions {
|
|
36
|
+
/** Token to chart — pool address, base-token address, or pair symbol (e.g. "PEPE"). */
|
|
37
|
+
token: string;
|
|
38
|
+
/** Engine base URL (default https://signals.livo.build). */
|
|
39
|
+
url?: string;
|
|
40
|
+
/** Candle bucket size in seconds (default 300 = 5m). */
|
|
41
|
+
bucketSeconds?: number;
|
|
42
|
+
/** Swaps fetched per history page from the index (default 1500); older pages load lazily. */
|
|
43
|
+
swapPageSize?: number;
|
|
44
|
+
/** Candles to load before the first paint, so the chart opens filled rather than a sliver
|
|
45
|
+
* on the right (default 160). Active pools pack many swaps per bucket, so this may take a
|
|
46
|
+
* few index pages. */
|
|
47
|
+
initialCandles?: number;
|
|
48
|
+
/** Force `"spark"` (snapshot closes only) instead of the default `"index"` (GraphQL history). */
|
|
49
|
+
history?: "index" | "spark";
|
|
50
|
+
/** Live poll cadence in ms (default 15s; 0 disables the live poll). */
|
|
51
|
+
refetchMs?: number;
|
|
52
|
+
/** Override "now" (ms) — for tests/determinism. */
|
|
53
|
+
now?: number;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A {@link ChartFeed} for a Signal Radar token. History comes from the engine's indexed
|
|
57
|
+
* swaps (`/graphql allSwaps` for the pool → bucketed into OHLC at `bucketSeconds`), paged
|
|
58
|
+
* lazily on back-scroll, so it shows real candles days deep. The live candle is polled from
|
|
59
|
+
* `/data` (current price). If the index is unreachable (or `history: "spark"`), it falls back
|
|
60
|
+
* to seeding from the snapshot's `spark`. Pass it to `connectFeed` with `bucketSeconds` as
|
|
61
|
+
* the interval.
|
|
62
|
+
*/
|
|
63
|
+
export declare function signalsFeed(opts: SignalsFeedOptions): ChartFeed;
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Signal Radar adapter — chart a token tracked by the Livo on-chain signals engine.
|
|
2
|
+
// The engine ("Signal Radar") indexes every Ethereum mainnet DEX swap into Postgres and
|
|
3
|
+
// exposes it over a PostGraphile GraphQL API (`/graphql`), plus a live `/data` snapshot
|
|
4
|
+
// (no key) with the current USD `price` and a short `spark` of recent closes.
|
|
5
|
+
//
|
|
6
|
+
// This adapter pulls REAL history from the index — `allSwaps` for the pool, bucketed into
|
|
7
|
+
// OHLC candles at any interval — so it shows true 1m / 5m candles days back (the index has
|
|
8
|
+
// no TTL), and lazy-loads older pages as you scroll. It polls `/data` for the live, building
|
|
9
|
+
// candle. If `/graphql` is unreachable (e.g. CORS), it falls back to seeding from `spark`.
|
|
10
|
+
//
|
|
11
|
+
// import { Chart, connectFeed, signalsFeed } from "@livo-build/charts";
|
|
12
|
+
// const chart = new Chart(el, { height: 420 });
|
|
13
|
+
// connectFeed(chart, signalsFeed({ token: "PEPE" }), { interval: 300 });
|
|
14
|
+
import { buildOHLC } from "./ohlc";
|
|
15
|
+
/** The public Livo signals engine. */
|
|
16
|
+
export const DEFAULT_SIGNALS_URL = "https://signals.livo.build";
|
|
17
|
+
/** fetch with an abort timeout so a stalled engine request can't wedge lazy-loading forever. */
|
|
18
|
+
async function fetchT(input, init = {}, timeoutMs = 12000) {
|
|
19
|
+
const ac = typeof AbortController !== "undefined" ? new AbortController() : null;
|
|
20
|
+
const timer = ac ? setTimeout(() => ac.abort(), timeoutMs) : undefined;
|
|
21
|
+
try {
|
|
22
|
+
return await fetch(input, ac ? { ...init, signal: ac.signal } : init);
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
if (timer)
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const bare = (s) => s.replace(/^0x/i, "").toLowerCase();
|
|
30
|
+
/** Find the snapshot market for a token by pool address, base-token address, or pair symbol. */
|
|
31
|
+
function findMarket(markets, token) {
|
|
32
|
+
const q = bare(token);
|
|
33
|
+
const byAddr = markets.find((m) => bare(m.pool) === q || bare(m.base) === q);
|
|
34
|
+
if (byAddr)
|
|
35
|
+
return byAddr;
|
|
36
|
+
const sym = token.toLowerCase();
|
|
37
|
+
// symbol match → the deepest (first listed; the snapshot is hottest-first) market.
|
|
38
|
+
return markets.find((m) => m.pair.toLowerCase().split("/")[0] === sym);
|
|
39
|
+
}
|
|
40
|
+
/** Fetch one Signal Radar market (price + spark) from the engine snapshot, or null if absent. */
|
|
41
|
+
export async function fetchSignalsMarket(token, url = DEFAULT_SIGNALS_URL) {
|
|
42
|
+
const res = await fetchT(`${url}/data`);
|
|
43
|
+
if (!res.ok)
|
|
44
|
+
throw new Error("signals /data " + res.status);
|
|
45
|
+
const snap = (await res.json());
|
|
46
|
+
return findMarket(snap.markets ?? [], token) ?? null;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Fetch a page of indexed swaps for a pool from the engine's GraphQL API (`allSwaps`,
|
|
50
|
+
* newest-first), mapped to priced {@link Point}s. `pool` must be the 0x-prefixed address.
|
|
51
|
+
* Returns one point per swap (unpriceable swaps keep `p: 0` and are dropped by `buildOHLC`),
|
|
52
|
+
* so the caller can advance its offset by `points.length`.
|
|
53
|
+
*/
|
|
54
|
+
export async function fetchSignalsSwaps(pool, opts = {}) {
|
|
55
|
+
const url = opts.url ?? DEFAULT_SIGNALS_URL;
|
|
56
|
+
const first = opts.first ?? 1500;
|
|
57
|
+
const offset = opts.offset ?? 0;
|
|
58
|
+
const query = `{ allSwaps(condition:{pool:"${pool}"}, orderBy: BLOCK_TIME_DESC, first:${first}, offset:${offset}){ nodes{ blockTime priceUsd amountUsd } } }`;
|
|
59
|
+
const res = await fetchT(`${url}/graphql`, {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: { "content-type": "application/json" },
|
|
62
|
+
body: JSON.stringify({ query }),
|
|
63
|
+
});
|
|
64
|
+
if (!res.ok)
|
|
65
|
+
throw new Error("signals /graphql " + res.status);
|
|
66
|
+
const j = (await res.json());
|
|
67
|
+
const nodes = j?.data?.allSwaps?.nodes;
|
|
68
|
+
if (!nodes)
|
|
69
|
+
throw new Error("signals /graphql: no allSwaps");
|
|
70
|
+
return nodes.map((s) => ({ t: +s.blockTime, p: +(s.priceUsd ?? 0), v: +(s.amountUsd ?? 0) || 0 }));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Drop swaps whose price is a wild outlier — the engine occasionally mis-prices historical
|
|
74
|
+
* swaps (e.g. a thin pool's old trades priced at $2.5M for a $3 token). Uses the median of
|
|
75
|
+
* the newest swaps as a robust reference (recent prices are reliable) and rejects anything
|
|
76
|
+
* more than `factor`× off it. Input is newest-first; output preserves order.
|
|
77
|
+
*/
|
|
78
|
+
export function rejectPriceOutliers(pts, factor = 100) {
|
|
79
|
+
const priced = pts.filter((p) => p.p > 0 && isFinite(p.p));
|
|
80
|
+
if (priced.length < 8)
|
|
81
|
+
return priced;
|
|
82
|
+
const recent = priced
|
|
83
|
+
.slice(0, 200)
|
|
84
|
+
.map((p) => p.p)
|
|
85
|
+
.sort((a, b) => a - b);
|
|
86
|
+
const ref = recent[Math.floor(recent.length / 2)] || 0;
|
|
87
|
+
if (!(ref > 0))
|
|
88
|
+
return priced;
|
|
89
|
+
return priced.filter((p) => p.p >= ref / factor && p.p <= ref * factor);
|
|
90
|
+
}
|
|
91
|
+
/** Turn a `spark` (recent closes, oldest→newest) into flat candles ending at `nowSec`. */
|
|
92
|
+
export function sparkCandles(spark, bucketSeconds, nowSec, lastClose) {
|
|
93
|
+
const n = spark.length;
|
|
94
|
+
const endBucket = Math.floor(nowSec / bucketSeconds) * bucketSeconds;
|
|
95
|
+
return spark.map((p, i) => {
|
|
96
|
+
const c = i === n - 1 && lastClose != null ? lastClose : p;
|
|
97
|
+
const time = endBucket - (n - 1 - i) * bucketSeconds;
|
|
98
|
+
return { time, o: p, h: Math.max(p, c), l: Math.min(p, c), c, vol: 0 };
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* A {@link ChartFeed} for a Signal Radar token. History comes from the engine's indexed
|
|
103
|
+
* swaps (`/graphql allSwaps` for the pool → bucketed into OHLC at `bucketSeconds`), paged
|
|
104
|
+
* lazily on back-scroll, so it shows real candles days deep. The live candle is polled from
|
|
105
|
+
* `/data` (current price). If the index is unreachable (or `history: "spark"`), it falls back
|
|
106
|
+
* to seeding from the snapshot's `spark`. Pass it to `connectFeed` with `bucketSeconds` as
|
|
107
|
+
* the interval.
|
|
108
|
+
*/
|
|
109
|
+
export function signalsFeed(opts) {
|
|
110
|
+
const url = opts.url ?? DEFAULT_SIGNALS_URL;
|
|
111
|
+
const secs = opts.bucketSeconds ?? 300;
|
|
112
|
+
const token = opts.token;
|
|
113
|
+
const swapPage = opts.swapPageSize ?? 1500;
|
|
114
|
+
const initialCandles = opts.initialCandles ?? 160;
|
|
115
|
+
const sparkOnly = opts.history === "spark";
|
|
116
|
+
const MAX_SWAPS = 60000; // memory/CPU bound on deep back-scroll
|
|
117
|
+
// Resolve the market once (gives the pool to query + the spark fallback), cached.
|
|
118
|
+
let marketP = null;
|
|
119
|
+
const market = () => (marketP ?? (marketP = fetchSignalsMarket(token, url).catch(() => null)));
|
|
120
|
+
// Accumulate ALL fetched swaps and re-bucket the full set each page, so a candle bucket
|
|
121
|
+
// that straddles a swap-page boundary is always COMPLETE (no partial O/H/L/volume). The
|
|
122
|
+
// feed returns the full ascending candle set; connectFeed merges it (replacing the partial
|
|
123
|
+
// boundary it already showed). `offset` pages older swaps; bounded by MAX_SWAPS.
|
|
124
|
+
// When `token` is a pool address, query the index by it DIRECTLY — the index keys on the
|
|
125
|
+
// pool (permanent) so history works even after the market rotates out of the live `/data`
|
|
126
|
+
// snapshot. /data is then only needed for the spark fallback + the live price.
|
|
127
|
+
const directPool = /^(0x)?[0-9a-fA-F]{40}$/.test(token) ? `0x${bare(token)}` : null;
|
|
128
|
+
let allSwaps = [];
|
|
129
|
+
let offset = 0;
|
|
130
|
+
let sparkMode = sparkOnly; // flipped on if the index is unreachable
|
|
131
|
+
let histLatest = null; // newest indexed candle (seeds the live bucket)
|
|
132
|
+
const spark = async (m) => {
|
|
133
|
+
if (!m)
|
|
134
|
+
return [];
|
|
135
|
+
const nowSec = (opts.now ?? Date.now()) / 1000;
|
|
136
|
+
const cs = sparkCandles(m.spark ?? [], secs, nowSec, m.price);
|
|
137
|
+
histLatest = cs[cs.length - 1] ?? null;
|
|
138
|
+
return cs;
|
|
139
|
+
};
|
|
140
|
+
return {
|
|
141
|
+
async loadHistory({ before }) {
|
|
142
|
+
const m = await market(); // for the spark fallback + the live price (best-effort)
|
|
143
|
+
// Prefer the pool straight from the token (rotation-proof); else resolve it via /data.
|
|
144
|
+
const pool = directPool ?? (m && m.pool ? `0x${bare(m.pool)}` : null);
|
|
145
|
+
if (!pool)
|
|
146
|
+
return [];
|
|
147
|
+
// Spark mode (forced via `history:"spark"`, or fell back): only the initial page has
|
|
148
|
+
// data (the snapshot's recent closes); there are no deeper pages.
|
|
149
|
+
if (sparkMode)
|
|
150
|
+
return before == null ? spark(m) : [];
|
|
151
|
+
if (allSwaps.length >= MAX_SWAPS)
|
|
152
|
+
return []; // depth cap reached
|
|
153
|
+
try {
|
|
154
|
+
// On the FIRST load, keep pulling pages until we have a full screen of candles, so the
|
|
155
|
+
// chart paints filled rather than a sliver on the right (active pools pack many swaps
|
|
156
|
+
// into each bucket, so one page can be only an hour or two). Lazy scroll-back fetches
|
|
157
|
+
// one page at a time. `rejectPriceOutliers` drops engine mis-pricings (e.g. $2.5M).
|
|
158
|
+
const want = before == null ? initialCandles : 0;
|
|
159
|
+
let got = 0;
|
|
160
|
+
do {
|
|
161
|
+
const pts = await fetchSignalsSwaps(pool, { url, first: swapPage, offset });
|
|
162
|
+
offset += pts.length;
|
|
163
|
+
got += pts.length;
|
|
164
|
+
if (pts.length === 0)
|
|
165
|
+
break;
|
|
166
|
+
allSwaps = allSwaps.concat(pts);
|
|
167
|
+
} while (allSwaps.length < MAX_SWAPS && buildOHLC(rejectPriceOutliers(allSwaps), secs).length < want);
|
|
168
|
+
if (got === 0) {
|
|
169
|
+
if (allSwaps.length === 0) {
|
|
170
|
+
sparkMode = true; // index empty for this pool — seed from spark
|
|
171
|
+
return before == null ? spark(m) : [];
|
|
172
|
+
}
|
|
173
|
+
return []; // no older swaps — history exhausted
|
|
174
|
+
}
|
|
175
|
+
const candles = buildOHLC(rejectPriceOutliers(allSwaps), secs); // clean, complete buckets
|
|
176
|
+
histLatest = candles[candles.length - 1] ?? null; // newest (may be the live bucket) — seeds the live poll
|
|
177
|
+
// EXCLUDE the in-progress (current wall-clock) bucket: the live poll owns it. Otherwise a
|
|
178
|
+
// history re-fetch (lazy load) would overwrite the live candle with the stale indexed close,
|
|
179
|
+
// and the next poll would restore the live price — making the latest candle oscillate.
|
|
180
|
+
const curBucket = Math.floor((opts.now ?? Date.now()) / 1000 / secs) * secs;
|
|
181
|
+
return candles.filter((c) => c.time < curBucket);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// index unreachable (e.g. /graphql CORS) — fall back to the snapshot spark, once.
|
|
185
|
+
sparkMode = true;
|
|
186
|
+
return before == null ? spark(m) : [];
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
subscribe(_params, onUpdate) {
|
|
190
|
+
const ms = opts.refetchMs ?? 15000;
|
|
191
|
+
if (ms <= 0)
|
|
192
|
+
return () => { };
|
|
193
|
+
let stopped = false;
|
|
194
|
+
let timer;
|
|
195
|
+
// Track the in-progress bucket so polled prices accumulate high/low instead of
|
|
196
|
+
// overwriting the whole candle each tick.
|
|
197
|
+
let cur = null;
|
|
198
|
+
const tick = async () => {
|
|
199
|
+
if (stopped)
|
|
200
|
+
return;
|
|
201
|
+
try {
|
|
202
|
+
const m = await fetchSignalsMarket(token, url);
|
|
203
|
+
if (m && !stopped) {
|
|
204
|
+
const nowSec = (opts.now ?? Date.now()) / 1000;
|
|
205
|
+
const bt = Math.floor(nowSec / secs) * secs;
|
|
206
|
+
if (!cur || cur.time !== bt) {
|
|
207
|
+
// Seed the in-progress bucket from the latest INDEXED candle when it's the same
|
|
208
|
+
// bucket, so the live price extends real OHLC/volume instead of flattening it.
|
|
209
|
+
const base = histLatest && histLatest.time === bt ? histLatest : null;
|
|
210
|
+
cur = base
|
|
211
|
+
? { ...base, h: Math.max(base.h, m.price), l: Math.min(base.l, m.price), c: m.price }
|
|
212
|
+
: { time: bt, o: m.price, h: m.price, l: m.price, c: m.price, vol: 0 };
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
cur = { ...cur, h: Math.max(cur.h, m.price), l: Math.min(cur.l, m.price), c: m.price };
|
|
216
|
+
}
|
|
217
|
+
onUpdate(cur);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
/* transient fetch error — try again next tick */
|
|
222
|
+
}
|
|
223
|
+
if (!stopped)
|
|
224
|
+
timer = setTimeout(tick, ms);
|
|
225
|
+
};
|
|
226
|
+
void tick(); // fire immediately so the live (current-bucket) candle appears at once
|
|
227
|
+
return () => {
|
|
228
|
+
stopped = true;
|
|
229
|
+
if (timer)
|
|
230
|
+
clearTimeout(timer);
|
|
231
|
+
};
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|