@livo-build/charts 0.2.1 → 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/dist/core/feed.js CHANGED
@@ -40,14 +40,24 @@ export function connectFeed(chart, feed, opts) {
40
40
  return;
41
41
  loadingOlder = true;
42
42
  try {
43
+ const oldest = buf.length ? buf[0].time : Infinity;
43
44
  const before = buf.length ? buf[0].time * 1000 : undefined;
44
45
  const older = await feed.loadHistory({ interval: opts.interval, before, limit });
45
- const seen = new Set(buf.map((c) => c.time));
46
- const fresh = older.filter((c) => !seen.has(c.time)).sort((a, b) => a.time - b.time);
47
- if (fresh.length === 0)
46
+ // Merge by time. An incoming candle for an EXISTING bucket replaces it — a feed that
47
+ // re-buckets accumulated data (e.g. signalsFeed) returns a more-complete boundary
48
+ // candle than the partial one already shown. Strictly-older candles extend history.
49
+ // Stop only when nothing older than the current oldest arrives.
50
+ let addedOlder = false;
51
+ const byTime = new Map(buf.map((c) => [c.time, c]));
52
+ for (const c of older) {
53
+ if (c.time < oldest)
54
+ addedOlder = true;
55
+ byTime.set(c.time, c);
56
+ }
57
+ if (!addedOlder)
48
58
  exhausted = true;
49
59
  else
50
- push([...fresh, ...buf]);
60
+ push([...byTime.values()].sort((a, b) => a.time - b.time));
51
61
  }
52
62
  catch (e) {
53
63
  opts.onError?.(e);
@@ -58,6 +68,10 @@ export function connectFeed(chart, feed, opts) {
58
68
  };
59
69
  chart.setInterval(opts.interval);
60
70
  chart.setNeedHistory(() => void loadOlder());
71
+ // Clear any candles from a previous connection (e.g. an interval switch reconnects with a
72
+ // new feed): otherwise the old bars linger, mislabeled at the new interval, until the first
73
+ // page arrives. Reset to empty so the host's loading state shows instead of stale data.
74
+ chart.setCandles([]);
61
75
  // initial page + live subscription
62
76
  void (async () => {
63
77
  try {
@@ -67,12 +81,19 @@ export function connectFeed(chart, feed, opts) {
67
81
  catch (e) {
68
82
  opts.onError?.(e);
69
83
  }
70
- if (feed.subscribe) {
71
- unsub = feed.subscribe({ interval: opts.interval }, (c) => {
84
+ // Guard against a disconnect that happened DURING the initial load: don't open a live
85
+ // stream after teardown (it would leak a WebSocket / poll timer), and if disconnect
86
+ // races the subscribe call, tear the stream down immediately.
87
+ if (feed.subscribe && live) {
88
+ const u = feed.subscribe({ interval: opts.interval }, (c) => {
72
89
  const next = mergeLive(buf, c);
73
90
  if (next !== buf)
74
91
  push(next);
75
92
  });
93
+ if (live)
94
+ unsub = u;
95
+ else
96
+ u();
76
97
  }
77
98
  })();
78
99
  return {
@@ -1,5 +1,17 @@
1
- /** Compact value formatter: large numbers grouped (K/M/B), small numbers to significant digits. */
1
+ /**
2
+ * Value formatter for price axes + OHLC readouts. M/B compaction only kicks in at
3
+ * a million — between $1k and $1M the FULL grouped number is shown (1,730 not the
4
+ * lossy "1.73K"), so adjacent axis gridlines and a candle's O/H/L/C stay
5
+ * distinguishable on mid-priced assets. Trailing zeros are dropped.
6
+ */
2
7
  export declare function formatValue(v: number): string;
8
+ /**
9
+ * Axis value formatter that adapts decimal precision to the tick `step` so adjacent
10
+ * gridline labels stay distinct. The compact formatter rounds to 2 decimals (≈$10 at
11
+ * the "K" scale), which collapses a 1.71K–1.75K axis into duplicate "1.73K"s; passing
12
+ * the per-tick step keeps just enough digits to separate them (e.g. 1.7350K / 1.7430K).
13
+ */
14
+ export declare function formatAxisValue(v: number, step?: number): string;
3
15
  /** Volume formatter with a leading `$`. */
4
16
  export declare function formatVolume(v: number): string;
5
17
  /** Axis time label whose granularity follows the bucket `interval` (seconds). */
@@ -1,4 +1,9 @@
1
- /** Compact value formatter: large numbers grouped (K/M/B), small numbers to significant digits. */
1
+ /**
2
+ * Value formatter for price axes + OHLC readouts. M/B compaction only kicks in at
3
+ * a million — between $1k and $1M the FULL grouped number is shown (1,730 not the
4
+ * lossy "1.73K"), so adjacent axis gridlines and a candle's O/H/L/C stay
5
+ * distinguishable on mid-priced assets. Trailing zeros are dropped.
6
+ */
2
7
  export function formatValue(v) {
3
8
  if (!isFinite(v))
4
9
  return "-";
@@ -8,13 +13,43 @@ export function formatValue(v) {
8
13
  if (a >= 1e6)
9
14
  return (v / 1e6).toFixed(2) + "M";
10
15
  if (a >= 1e3)
11
- return (v / 1e3).toFixed(2) + "K";
16
+ return v.toLocaleString("en-US", { maximumFractionDigits: a >= 1e4 ? 0 : 2 });
12
17
  if (a >= 1)
13
- return v.toFixed(2);
18
+ return v.toLocaleString("en-US", { maximumFractionDigits: a >= 100 ? 2 : 4 });
14
19
  if (a > 0)
15
20
  return v.toPrecision(4);
16
21
  return "0";
17
22
  }
23
+ /**
24
+ * Axis value formatter that adapts decimal precision to the tick `step` so adjacent
25
+ * gridline labels stay distinct. The compact formatter rounds to 2 decimals (≈$10 at
26
+ * the "K" scale), which collapses a 1.71K–1.75K axis into duplicate "1.73K"s; passing
27
+ * the per-tick step keeps just enough digits to separate them (e.g. 1.7350K / 1.7430K).
28
+ */
29
+ export function formatAxisValue(v, step = 0) {
30
+ if (!isFinite(v))
31
+ return "-";
32
+ const a = Math.abs(v);
33
+ let div = 1;
34
+ let suf = "";
35
+ if (a >= 1e9) {
36
+ div = 1e9;
37
+ suf = "B";
38
+ }
39
+ else if (a >= 1e6) {
40
+ div = 1e6;
41
+ suf = "M";
42
+ }
43
+ else if (a >= 1e3) {
44
+ div = 1e3;
45
+ suf = "K";
46
+ }
47
+ // decimals fine enough to tell two ticks `step` apart; floor at 1 for K/M/B, 2 otherwise.
48
+ const floor = div > 1 ? 1 : 2;
49
+ const s = Math.abs(step) / div;
50
+ const dec = s > 0 ? Math.max(floor === 2 ? 0 : floor, Math.min(8, Math.ceil(-Math.log10(s)))) : floor;
51
+ return (v / div).toFixed(dec) + suf;
52
+ }
18
53
  /** Volume formatter with a leading `$`. */
19
54
  export function formatVolume(v) {
20
55
  const a = Math.abs(v);
@@ -30,6 +30,56 @@ export declare function bollingerBands(values: number[], period?: number, mult?:
30
30
  mid: (number | null)[];
31
31
  lower: (number | null)[];
32
32
  };
33
+ /**
34
+ * Wilder's Relative Strength Index over `period` (default 14). Output aligned to input;
35
+ * null until `period` deltas are available. Bounded 0–100 (100 when there are no losses).
36
+ */
37
+ export declare function rsi(values: number[], period?: number): (number | null)[];
38
+ /**
39
+ * MACD: the difference of a `fast` and `slow` EMA, its `signal` EMA, and the histogram
40
+ * (macd − signal). Three series aligned to input; null until each EMA's window fills.
41
+ */
42
+ export declare function macd(values: number[], fast?: number, slow?: number, signal?: number): {
43
+ macd: (number | null)[];
44
+ signal: (number | null)[];
45
+ hist: (number | null)[];
46
+ };
47
+ /**
48
+ * Stochastic oscillator. `%K = 100·(close − lowestLow) / (highestHigh − lowestLow)` over
49
+ * `kPeriod` (default 14), optionally smoothed by an SMA of `smooth` (default 1 = "fast"),
50
+ * and `%D` = SMA(%K, `dPeriod`, default 3). Both bounded 0–100, aligned to input.
51
+ */
52
+ export declare function stochastic(candles: Candle[], kPeriod?: number, dPeriod?: number, smooth?: number): {
53
+ k: (number | null)[];
54
+ d: (number | null)[];
55
+ };
56
+ /**
57
+ * Average True Range (Wilder) — a volatility measure in price units. TR is the greatest of
58
+ * (high−low), |high−prevClose|, |low−prevClose|; ATR seeds with the SMA of the first
59
+ * `period` TRs (default 14) then Wilder-smooths. Aligned to input; null in the warmup.
60
+ */
61
+ export declare function atr(candles: Candle[], period?: number): (number | null)[];
62
+ /** One price level of a {@link volumeProfile} histogram. `lo`/`hi` bound the bucket. */
63
+ export interface VolumeBucket {
64
+ lo: number;
65
+ hi: number;
66
+ vol: number;
67
+ }
68
+ /** A computed volume-by-price profile: equal-height buckets plus the point of control. */
69
+ export interface VolumeProfileResult {
70
+ buckets: VolumeBucket[];
71
+ /** Largest bucket volume (for scaling the bars). */
72
+ maxVol: number;
73
+ /** Mid-price of the highest-volume bucket (the Point of Control). */
74
+ poc: number;
75
+ }
76
+ /**
77
+ * Volume-by-price histogram. Splits the candles' price range into `buckets` equal bands
78
+ * and distributes each candle's volume evenly across the bands its low→high spans (so a
79
+ * wide bar contributes to several levels). Returns the bands, the peak volume, and the
80
+ * Point of Control (mid-price of the heaviest band).
81
+ */
82
+ export declare function volumeProfile(candles: Candle[], buckets?: number): VolumeProfileResult;
33
83
  /** Pull a price series (default close) out of candles. */
34
84
  export declare function sourceValues(candles: Candle[], source?: PriceSource): number[];
35
85
  /** Compute an indicator's value series, aligned to `candles` (null where undefined). */
@@ -93,6 +93,187 @@ export function bollingerBands(values, period = 20, mult = 2) {
93
93
  }
94
94
  return { upper, mid, lower };
95
95
  }
96
+ /**
97
+ * Wilder's Relative Strength Index over `period` (default 14). Output aligned to input;
98
+ * null until `period` deltas are available. Bounded 0–100 (100 when there are no losses).
99
+ */
100
+ export function rsi(values, period = 14) {
101
+ const out = new Array(values.length).fill(null);
102
+ if (!(period > 0) || values.length <= period)
103
+ return out;
104
+ let gain = 0;
105
+ let loss = 0;
106
+ for (let i = 1; i <= period; i++) {
107
+ const d = values[i] - values[i - 1];
108
+ if (d >= 0)
109
+ gain += d;
110
+ else
111
+ loss -= d;
112
+ }
113
+ let avgG = gain / period;
114
+ let avgL = loss / period;
115
+ out[period] = avgL === 0 ? 100 : 100 - 100 / (1 + avgG / avgL);
116
+ for (let i = period + 1; i < values.length; i++) {
117
+ const d = values[i] - values[i - 1];
118
+ avgG = (avgG * (period - 1) + (d > 0 ? d : 0)) / period;
119
+ avgL = (avgL * (period - 1) + (d < 0 ? -d : 0)) / period;
120
+ out[i] = avgL === 0 ? 100 : 100 - 100 / (1 + avgG / avgL);
121
+ }
122
+ return out;
123
+ }
124
+ /** EMA over a sparse series (nulls before the first value), seeded with the SMA of the
125
+ * first `period` defined values. Used to take the signal EMA of the MACD line. */
126
+ function emaNullable(series, period) {
127
+ const out = new Array(series.length).fill(null);
128
+ const start = series.findIndex((v) => v != null);
129
+ if (start < 0 || !(period > 0) || series.length - start < period)
130
+ return out;
131
+ const k = 2 / (period + 1);
132
+ let seed = 0;
133
+ for (let i = start; i < start + period; i++)
134
+ seed += series[i];
135
+ let prev = seed / period;
136
+ out[start + period - 1] = prev;
137
+ for (let i = start + period; i < series.length; i++) {
138
+ const v = series[i];
139
+ if (v == null)
140
+ continue;
141
+ prev = v * k + prev * (1 - k);
142
+ out[i] = prev;
143
+ }
144
+ return out;
145
+ }
146
+ /**
147
+ * MACD: the difference of a `fast` and `slow` EMA, its `signal` EMA, and the histogram
148
+ * (macd − signal). Three series aligned to input; null until each EMA's window fills.
149
+ */
150
+ export function macd(values, fast = 12, slow = 26, signal = 9) {
151
+ const ef = ema(values, fast);
152
+ const es = ema(values, slow);
153
+ const line = values.map((_, i) => (ef[i] != null && es[i] != null ? ef[i] - es[i] : null));
154
+ const sig = emaNullable(line, signal);
155
+ const hist = line.map((m, i) => (m != null && sig[i] != null ? m - sig[i] : null));
156
+ return { macd: line, signal: sig, hist };
157
+ }
158
+ /**
159
+ * Stochastic oscillator. `%K = 100·(close − lowestLow) / (highestHigh − lowestLow)` over
160
+ * `kPeriod` (default 14), optionally smoothed by an SMA of `smooth` (default 1 = "fast"),
161
+ * and `%D` = SMA(%K, `dPeriod`, default 3). Both bounded 0–100, aligned to input.
162
+ */
163
+ export function stochastic(candles, kPeriod = 14, dPeriod = 3, smooth = 1) {
164
+ const n = candles.length;
165
+ const rawK = new Array(n).fill(null);
166
+ if (!(kPeriod > 0))
167
+ return { k: rawK, d: rawK.slice() };
168
+ for (let i = kPeriod - 1; i < n; i++) {
169
+ let lo = Infinity;
170
+ let hi = -Infinity;
171
+ for (let j = i - kPeriod + 1; j <= i; j++) {
172
+ lo = Math.min(lo, candles[j].l);
173
+ hi = Math.max(hi, candles[j].h);
174
+ }
175
+ const rng = hi - lo;
176
+ rawK[i] = rng > 0 ? (100 * (candles[i].c - lo)) / rng : 100;
177
+ }
178
+ const k = smooth > 1 ? smaNullable(rawK, smooth) : rawK;
179
+ const d = smaNullable(k, dPeriod);
180
+ return { k, d };
181
+ }
182
+ /** SMA over a series that may contain leading nulls (used to smooth %K / %D). */
183
+ function smaNullable(series, period) {
184
+ const out = new Array(series.length).fill(null);
185
+ if (!(period > 0))
186
+ return out;
187
+ let sum = 0;
188
+ let cnt = 0;
189
+ const win = [];
190
+ for (let i = 0; i < series.length; i++) {
191
+ const v = series[i];
192
+ if (v == null) {
193
+ // a null breaks the window — restart so we never average across a gap.
194
+ sum = 0;
195
+ cnt = 0;
196
+ win.length = 0;
197
+ continue;
198
+ }
199
+ win.push(v);
200
+ sum += v;
201
+ cnt++;
202
+ if (cnt > period)
203
+ sum -= win[cnt - period - 1];
204
+ if (cnt >= period)
205
+ out[i] = sum / period;
206
+ }
207
+ return out;
208
+ }
209
+ /**
210
+ * Average True Range (Wilder) — a volatility measure in price units. TR is the greatest of
211
+ * (high−low), |high−prevClose|, |low−prevClose|; ATR seeds with the SMA of the first
212
+ * `period` TRs (default 14) then Wilder-smooths. Aligned to input; null in the warmup.
213
+ */
214
+ export function atr(candles, period = 14) {
215
+ const n = candles.length;
216
+ const out = new Array(n).fill(null);
217
+ if (!(period > 0) || n <= period)
218
+ return out;
219
+ const tr = new Array(n).fill(0);
220
+ tr[0] = candles[0].h - candles[0].l;
221
+ for (let i = 1; i < n; i++) {
222
+ const pc = candles[i - 1].c;
223
+ tr[i] = Math.max(candles[i].h - candles[i].l, Math.abs(candles[i].h - pc), Math.abs(candles[i].l - pc));
224
+ }
225
+ // seed = SMA of the first `period` TRs (placed at index `period`, like RSI's warmup).
226
+ let prev = 0;
227
+ for (let i = 1; i <= period; i++)
228
+ prev += tr[i];
229
+ prev /= period;
230
+ out[period] = prev;
231
+ for (let i = period + 1; i < n; i++) {
232
+ prev = (prev * (period - 1) + tr[i]) / period;
233
+ out[i] = prev;
234
+ }
235
+ return out;
236
+ }
237
+ /**
238
+ * Volume-by-price histogram. Splits the candles' price range into `buckets` equal bands
239
+ * and distributes each candle's volume evenly across the bands its low→high spans (so a
240
+ * wide bar contributes to several levels). Returns the bands, the peak volume, and the
241
+ * Point of Control (mid-price of the heaviest band).
242
+ */
243
+ export function volumeProfile(candles, buckets = 24) {
244
+ const B = Math.max(1, Math.round(buckets));
245
+ let lo = Infinity;
246
+ let hi = -Infinity;
247
+ for (const c of candles) {
248
+ lo = Math.min(lo, c.l);
249
+ hi = Math.max(hi, c.h);
250
+ }
251
+ if (!isFinite(lo) || !isFinite(hi) || hi <= lo) {
252
+ const v = candles.reduce((a, c) => a + (c.vol || 0), 0);
253
+ const p = isFinite(lo) ? lo : 0;
254
+ return { buckets: [{ lo: p, hi: p, vol: v }], maxVol: v, poc: p };
255
+ }
256
+ const step = (hi - lo) / B;
257
+ const bins = new Array(B).fill(0);
258
+ for (const c of candles) {
259
+ const from = Math.max(0, Math.min(B - 1, Math.floor((c.l - lo) / step)));
260
+ const to = Math.max(0, Math.min(B - 1, Math.floor((c.h - lo) / step)));
261
+ const span = to - from + 1;
262
+ const share = (c.vol || 0) / span;
263
+ for (let b = from; b <= to; b++)
264
+ bins[b] += share;
265
+ }
266
+ let maxVol = 0;
267
+ let pocIdx = 0;
268
+ const out = bins.map((vol, i) => {
269
+ if (vol > maxVol) {
270
+ maxVol = vol;
271
+ pocIdx = i;
272
+ }
273
+ return { lo: lo + i * step, hi: lo + (i + 1) * step, vol };
274
+ });
275
+ return { buckets: out, maxVol: maxVol || 1, poc: lo + (pocIdx + 0.5) * step };
276
+ }
96
277
  const SOURCE_KEY = { open: "o", high: "h", low: "l", close: "c" };
97
278
  /** Pull a price series (default close) out of candles. */
98
279
  export function sourceValues(candles, source = "close") {
@@ -7,3 +7,13 @@ export declare function transformPrice(p: number, t?: PriceTransform): number;
7
7
  * price is non-positive or non-finite are dropped; their volume is dropped with them.
8
8
  */
9
9
  export declare function buildOHLC(points: Point[], interval: number, transform?: PriceTransform): Candle[];
10
+ /**
11
+ * Heikin-Ashi transform: smoothed candles that filter noise and make trends easier to
12
+ * read. Each output keeps the input's `time` and `vol`; only OHLC are recomputed:
13
+ * HA_close = (o+h+l+c)/4
14
+ * HA_open = (prevHA_open + prevHA_close)/2 (seed: (o+c)/2)
15
+ * HA_high = max(h, HA_open, HA_close)
16
+ * HA_low = min(l, HA_open, HA_close)
17
+ * Input must be ascending by time; output is aligned 1:1.
18
+ */
19
+ export declare function heikinAshi(candles: Candle[]): Candle[];
package/dist/core/ohlc.js CHANGED
@@ -34,3 +34,33 @@ export function buildOHLC(points, interval, transform = {}) {
34
34
  }
35
35
  return [...buckets.values()].sort((a, b) => a.time - b.time);
36
36
  }
37
+ /**
38
+ * Heikin-Ashi transform: smoothed candles that filter noise and make trends easier to
39
+ * read. Each output keeps the input's `time` and `vol`; only OHLC are recomputed:
40
+ * HA_close = (o+h+l+c)/4
41
+ * HA_open = (prevHA_open + prevHA_close)/2 (seed: (o+c)/2)
42
+ * HA_high = max(h, HA_open, HA_close)
43
+ * HA_low = min(l, HA_open, HA_close)
44
+ * Input must be ascending by time; output is aligned 1:1.
45
+ */
46
+ export function heikinAshi(candles) {
47
+ const out = new Array(candles.length);
48
+ let prevO = 0;
49
+ let prevC = 0;
50
+ for (let i = 0; i < candles.length; i++) {
51
+ const c = candles[i];
52
+ const haC = (c.o + c.h + c.l + c.c) / 4;
53
+ const haO = i === 0 ? (c.o + c.c) / 2 : (prevO + prevC) / 2;
54
+ out[i] = {
55
+ time: c.time,
56
+ o: haO,
57
+ h: Math.max(c.h, haO, haC),
58
+ l: Math.min(c.l, haO, haC),
59
+ c: haC,
60
+ vol: c.vol,
61
+ };
62
+ prevO = haO;
63
+ prevC = haC;
64
+ }
65
+ return out;
66
+ }
@@ -0,0 +1,44 @@
1
+ import type { Point } from "./types";
2
+ import type { ChartFeed } from "./feed";
3
+ /** The public Polymarket CLOB host. */
4
+ export declare const POLYMARKET_CLOB = "https://clob.polymarket.com";
5
+ /** One raw `/prices-history` row: `t` is unix seconds, `p` is the price (probability 0–1). */
6
+ export interface PolyHistoryPoint {
7
+ t: number;
8
+ p: number;
9
+ }
10
+ export interface FetchPolyHistoryOptions {
11
+ /** The CLOB token id (an outcome of a market — see Polymarket's resolveMarket). */
12
+ tokenId: string;
13
+ /** Window start (unix seconds). Omit to use `interval` instead. */
14
+ startTs?: number;
15
+ /** Window end (unix seconds). Omit to use `interval` instead. */
16
+ endTs?: number;
17
+ /** Coarse window when `startTs`/`endTs` aren't given (default "1d"). */
18
+ interval?: "1m" | "1h" | "6h" | "1d" | "1w" | "max";
19
+ /** Resolution in minutes per point (e.g. 60 = hourly points). */
20
+ fidelity?: number;
21
+ /** Override the CLOB host. */
22
+ host?: string;
23
+ }
24
+ /** Fetch raw Polymarket price-history points (probabilities) for a token. */
25
+ export declare function fetchPolymarketPriceHistory(opts: FetchPolyHistoryOptions): Promise<Point[]>;
26
+ export interface PolymarketFeedOptions {
27
+ /** The CLOB token id to chart. */
28
+ tokenId: string;
29
+ /** Candle bucket size in seconds (default 3600 = 1h). Drives the price-history fidelity. */
30
+ bucketSeconds?: number;
31
+ /** Override the CLOB host. */
32
+ host?: string;
33
+ /** Live poll cadence in ms (default 30s; 0 disables the live poll). */
34
+ refetchMs?: number;
35
+ /** Override "now" (ms) — for tests/determinism. */
36
+ now?: number;
37
+ }
38
+ /**
39
+ * A {@link ChartFeed} for a Polymarket outcome: paged price history bucketed into OHLC
40
+ * candles, plus a polling "live" candle (Polymarket has no public candle socket, so the
41
+ * latest bucket is re-fetched on an interval). Price-history has no volume, so candles
42
+ * carry `vol: 0`. Pass it to `connectFeed` with `bucketSeconds` as the interval.
43
+ */
44
+ export declare function polymarketFeed(opts: PolymarketFeedOptions): ChartFeed;
@@ -0,0 +1,92 @@
1
+ // Polymarket adapter — chart any prediction-market outcome from the public CLOB
2
+ // price-history endpoint. Dependency-free (uses the global fetch); no API key needed
3
+ // for read-only price history. Prices are probabilities in [0, 1].
4
+ //
5
+ // import { Chart, connectFeed, polymarketFeed } from "@livo-build/charts";
6
+ // const chart = new Chart(el, { height: 420 });
7
+ // connectFeed(chart, polymarketFeed({ tokenId, bucketSeconds: 3600 }), { interval: 3600 });
8
+ import { buildOHLC } from "./ohlc";
9
+ /** The public Polymarket CLOB host. */
10
+ export const POLYMARKET_CLOB = "https://clob.polymarket.com";
11
+ /** fetch with an abort timeout so a stalled CLOB request can't wedge lazy-loading forever. */
12
+ async function fetchT(input, init = {}, timeoutMs = 12000) {
13
+ const ac = typeof AbortController !== "undefined" ? new AbortController() : null;
14
+ const timer = ac ? setTimeout(() => ac.abort(), timeoutMs) : undefined;
15
+ try {
16
+ return await fetch(input, ac ? { ...init, signal: ac.signal } : init);
17
+ }
18
+ finally {
19
+ if (timer)
20
+ clearTimeout(timer);
21
+ }
22
+ }
23
+ /** Fetch raw Polymarket price-history points (probabilities) for a token. */
24
+ export async function fetchPolymarketPriceHistory(opts) {
25
+ const host = opts.host ?? POLYMARKET_CLOB;
26
+ const p = new URLSearchParams({ market: opts.tokenId });
27
+ if (opts.startTs != null && opts.endTs != null) {
28
+ p.set("startTs", String(Math.floor(opts.startTs)));
29
+ p.set("endTs", String(Math.floor(opts.endTs)));
30
+ }
31
+ else {
32
+ p.set("interval", opts.interval ?? "1d");
33
+ }
34
+ if (opts.fidelity)
35
+ p.set("fidelity", String(Math.max(1, Math.round(opts.fidelity))));
36
+ const res = await fetchT(`${host}/prices-history?${p.toString()}`);
37
+ if (!res.ok)
38
+ throw new Error("Polymarket prices-history " + res.status);
39
+ const json = (await res.json());
40
+ return (json.history ?? []).map((h) => ({ t: h.t, p: h.p, v: 0 }));
41
+ }
42
+ /**
43
+ * A {@link ChartFeed} for a Polymarket outcome: paged price history bucketed into OHLC
44
+ * candles, plus a polling "live" candle (Polymarket has no public candle socket, so the
45
+ * latest bucket is re-fetched on an interval). Price-history has no volume, so candles
46
+ * carry `vol: 0`. Pass it to `connectFeed` with `bucketSeconds` as the interval.
47
+ */
48
+ export function polymarketFeed(opts) {
49
+ const secs = opts.bucketSeconds ?? 3600;
50
+ const host = opts.host ?? POLYMARKET_CLOB;
51
+ const tokenId = opts.tokenId;
52
+ const fidelity = Math.max(1, Math.round(secs / 60));
53
+ return {
54
+ async loadHistory({ before, limit }) {
55
+ const endTs = Math.floor((before ?? opts.now ?? Date.now()) / 1000);
56
+ const startTs = endTs - secs * limit;
57
+ const points = await fetchPolymarketPriceHistory({ tokenId, host, startTs, endTs, fidelity });
58
+ return buildOHLC(points, secs);
59
+ },
60
+ subscribe(_params, onUpdate) {
61
+ const ms = opts.refetchMs ?? 30000;
62
+ if (ms <= 0)
63
+ return () => { };
64
+ let stopped = false;
65
+ let timer;
66
+ const tick = async () => {
67
+ if (stopped)
68
+ return;
69
+ try {
70
+ // re-fetch the last few buckets and emit the most recent candle.
71
+ const endTs = Math.floor((opts.now ?? Date.now()) / 1000);
72
+ const points = await fetchPolymarketPriceHistory({ tokenId, host, startTs: endTs - secs * 3, endTs, fidelity });
73
+ const candles = buildOHLC(points, secs);
74
+ const last = candles[candles.length - 1];
75
+ if (last && !stopped)
76
+ onUpdate(last);
77
+ }
78
+ catch {
79
+ /* transient fetch error — try again next tick */
80
+ }
81
+ if (!stopped)
82
+ timer = setTimeout(tick, ms);
83
+ };
84
+ timer = setTimeout(tick, ms);
85
+ return () => {
86
+ stopped = true;
87
+ if (timer)
88
+ clearTimeout(timer);
89
+ };
90
+ },
91
+ };
92
+ }