@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.
@@ -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
- return { start, end, vis, n: vis.length, count, cw: slotWidth(plotW, count, maxBarWidth, fitContent) };
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
- const fwd = (v) => (log ? Math.log10(v) : v);
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
- export function timeScale(vis, n, cw, plotW, interval) {
56
- const xOf = (j) => plotW - (n - 1 - j) * cw - cw / 2;
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 { candles, view, pads, width, maxBarWidth, volH, height, yZoom, interval } = input;
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
- const fmtTime = input.timeFormat ?? formatTime;
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
- ctx.fillStyle = theme.axis;
135
- ctx.textAlign = "center";
136
- ctx.fillText("no priced trades yet", plotW / 2, H / 2);
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
- const { start, vis, n, cw } = windowOf(candles, view, plotW, maxBarWidth, input.fitContent);
144
- const { xOf, xOfTime } = timeScale(vis, n, cw, plotW, interval);
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
- let active = last;
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
- const idx = Math.max(0, Math.min(n - 1, Math.round((hover.x - (plotW - (n - 1) * cw - cw / 2)) / cw)));
323
- active = vis[idx];
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
- ctx.beginPath();
359
- if (d.type === "hline") {
360
- const y = yOfPrice(d.a.price);
361
- ctx.moveTo(0, y);
362
- ctx.lineTo(plotW, y);
363
- pts.push({ x: plotW / 2, y });
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
- ctx.moveTo(ax, ay);
371
- ctx.lineTo(bx, by);
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
- ctx.stroke();
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
+ }