dsh-hyperliquid 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ export type DrawingKind = 'level' | 'hray' | 'vline' | 'text' | 'trend' | 'ray' | 'extended' | 'fib' | 'rect' | 'measure' | 'channel' | 'long' | 'short' | 'fibext' | 'fibchannel' | 'fibtime' | 'gannfan' | 'pitchfork' | 'regression' | 'arrow' | 'callout' | 'circle' | 'triangle' | 'brush' | 'crossline' | 'pricelabel' | 'infoline' | 'trendangle' | 'pricerange' | 'daterange' | 'elliott' | 'elliottabc' | 'xabcd' | 'abcd' | 'headshoulders' | 'gannbox' | 'gannsquare' | 'schiff' | 'modschiff' | 'icon'
2
+ export type LineStyleName = 'solid' | 'dashed' | 'dotted'
3
+ export interface DrawingStyle { color?: string; lineWidth?: number; lineStyle?: LineStyleName; locked?: boolean; hidden?: boolean; shared?: boolean; alert?: boolean }
4
+ export declare const DRAWING_POINTS: Record<DrawingKind, number>
5
+ export declare const DRAWING_KINDS: DrawingKind[]
6
+ export declare const DRAWING_CLICKS: Record<DrawingKind, number>
7
+ export declare const LINE_STYLES: LineStyleName[]
8
+ export declare function drawingStyle(request: unknown): DrawingStyle
9
+ export declare const FIB_LEVELS: number[]
10
+ export declare function drawingGeometry(kind: string, request: unknown, now?: number): Record<string, unknown>
11
+ export declare const BRUSH_MIN: number
12
+ export declare const BRUSH_MAX: number
13
+ export declare const TEXT_KINDS: DrawingKind[]
14
+ export declare const FIB_EXT_LEVELS: number[]
15
+ export declare const FIB_TIME_SERIES: number[]
16
+ export declare const GANN_RATIOS: Array<[number, number]>
17
+ export declare const ICON_NAMES: string[]
18
+ export declare const GANN_BOX_LEVELS: number[]
19
+ export declare const STICKERS: Record<'trading' | 'faces' | 'symbols', string[]>
20
+ export declare function isIconLabel(label: unknown): boolean
21
+ export declare const ALERT_KINDS: DrawingKind[]
22
+ export declare function linePriceAt(mark: unknown, time: number): number | null
23
+ export declare function isSingleEmoji(value: unknown): boolean
@@ -0,0 +1,159 @@
1
+ /**
2
+ * dsh-hyperliquid — chart drawing kinds shared by the host (validation, the
3
+ * hyper_chart tool) and the browser chart (tools menu, rendering).
4
+ *
5
+ * level / trend keep their original flat fields (price; fromTime…toPrice);
6
+ * every other kind stores `points: [{ time, price }]` in click order.
7
+ * @module lib/chart-drawings
8
+ */
9
+
10
+ /** kind → number of anchor points (level: a price only). */
11
+ export const DRAWING_POINTS = {
12
+ level: 1, hray: 1, vline: 1, text: 1, trend: 2, ray: 2, extended: 2, fib: 2, rect: 2, measure: 2, channel: 3,
13
+ // Positions store [entry, stop, target]; the desk creates them with 2 clicks (target defaults to 2R).
14
+ long: 3, short: 3,
15
+ // Fibonacci family, Gann, pitchfork, regression.
16
+ fibext: 3, fibchannel: 3, fibtime: 2, gannfan: 2, pitchfork: 3, regression: 2,
17
+ // Annotation and shapes. brush is freehand: 0 = variable (BRUSH_MIN..BRUSH_MAX points).
18
+ arrow: 2, callout: 2, circle: 2, triangle: 3, brush: 0,
19
+ // More lines and measures: cross line, price label, info line, trend angle, price-only and date-only ranges.
20
+ crossline: 1, pricelabel: 1, infoline: 2, trendangle: 2, pricerange: 2, daterange: 2,
21
+ // Advanced: Elliott waves, harmonic / chart patterns, Gann box & square, pitchfork variants, icons.
22
+ elliott: 6, elliottabc: 4, xabcd: 5, abcd: 4, headshoulders: 7,
23
+ gannbox: 2, gannsquare: 2, schiff: 3, modschiff: 3, icon: 1,
24
+ }
25
+ /** Icon stickers (the drawing's label holds the icon name). */
26
+ export const ICON_NAMES = ['flag', 'star', 'up', 'down', 'warning', 'check', 'cross', 'fire', 'target', 'bell', 'pin', 'clock', 'dollar', 'shield', 'bolt', 'heart', 'question', 'info']
27
+ /** Sticker library (TradingView's emoji stickers): category → emoji. An icon label is a name or `emoji:<char>`. */
28
+ export const STICKERS = {
29
+ trading: ['📈', '📉', '💰', '💸', '🚀', '🎯', '🔥', '💎', '🐂', '🐻', '🐋', '🚨', '🔔', '⏰', '📌', '🧭', '🛑', '🏦', '⚖️', '🪙'],
30
+ faces: ['😀', '😂', '😎', '🤔', '😱', '😭', '😡', '🥳', '🤯', '🙏', '👍', '👎', '👀', '💪', '🫡', '🤝'],
31
+ symbols: ['⭐', '❗', '❓', '💡', '🔒', '🔓', '⛔', '🟢', '🔴', '🟡', '⬆️', '⬇️', '↗️', '↘️', '🏁', '🎉', '✅', '❌', '⚠️', '♻️', '⏸️', '▶️'],
32
+ }
33
+ const STICKER_SET = new Set(Object.values(STICKERS).flat())
34
+ /** Whether an icon drawing's label names a built-in icon or a library sticker. */
35
+ export function isIconLabel(label) {
36
+ if (ICON_NAMES.includes(label)) return true
37
+ if (typeof label !== 'string' || !label.startsWith('emoji:')) return false
38
+ const emoji = label.slice(6)
39
+ return STICKER_SET.has(emoji) || isSingleEmoji(emoji)
40
+ }
41
+
42
+ /** Any one emoji (a single grapheme that is pictographic, or a regional-indicator flag), so the whole emoji set works as stickers. */
43
+ export function isSingleEmoji(value) {
44
+ if (typeof value !== 'string' || !value || value.length > 32 || !/\p{Extended_Pictographic}|\p{Regional_Indicator}/u.test(value)) return false
45
+ return [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(value)].length === 1
46
+ }
47
+ /** Gann box divisions on both axes (TradingView defaults). */
48
+ export const GANN_BOX_LEVELS = [0, 0.25, 0.382, 0.5, 0.618, 0.75, 1]
49
+ /** Drawings a price can cross: alerts fire when the mid price crosses them (once, like TradingView). */
50
+ export const ALERT_KINDS = ['level', 'hray', 'trend', 'ray', 'extended']
51
+
52
+ /** The line's price at `time`, or null when the drawing does not exist at that time. */
53
+ export function linePriceAt(mark, time) {
54
+ if (mark.kind === 'level') return Number(mark.price)
55
+ const [a, b] = mark.kind === 'trend'
56
+ ? [{ time: mark.fromTime, price: mark.fromPrice }, { time: mark.toTime, price: mark.toPrice }]
57
+ : mark.points ?? []
58
+ if (!a) return null
59
+ if (mark.kind === 'hray') return time >= a.time ? a.price : null
60
+ if (!b || b.time === a.time) return null
61
+ if (mark.kind === 'trend' && (time < Math.min(a.time, b.time) || time > Math.max(a.time, b.time))) return null
62
+ if (mark.kind === 'ray' && (time - a.time) * (b.time - a.time) < 0) return null
63
+ return a.price + (b.price - a.price) * (time - a.time) / (b.time - a.time)
64
+ }
65
+
66
+ export const BRUSH_MIN = 2
67
+ export const BRUSH_MAX = 500
68
+ /** Kinds whose label is free text shown on the chart (up to 200 characters). */
69
+ export const TEXT_KINDS = ['text', 'callout']
70
+ /** Clicks that create a drawing when they differ from its stored anchor count. */
71
+ export const DRAWING_CLICKS = { ...Object.fromEntries(Object.entries(DRAWING_POINTS)), long: 2, short: 2 }
72
+ export const DRAWING_KINDS = Object.keys(DRAWING_POINTS)
73
+ /** Fibonacci retracement levels (0 = second point, 1 = first point, as in TradingView). */
74
+ export const FIB_LEVELS = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1]
75
+ /** Trend-based Fibonacci extension levels (projected from the third point, TradingView defaults). */
76
+ export const FIB_EXT_LEVELS = [0, 0.382, 0.618, 1, 1.272, 1.618, 2, 2.618]
77
+ /** Fibonacci time zones: bar multiples of the first two points' spacing. */
78
+ export const FIB_TIME_SERIES = [0, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89]
79
+ /** Gann fan angles as price/time ratios relative to the 1×1 line. */
80
+ export const GANN_RATIOS = [[1, 8], [1, 4], [1, 3], [1, 2], [1, 1], [2, 1], [3, 1], [4, 1], [8, 1]]
81
+
82
+ const OLDEST = Date.UTC(2015, 0, 1)
83
+
84
+ /**
85
+ * Validate the geometry of one drawing request (throws on invalid input).
86
+ * @returns the geometry fields to persist for that kind.
87
+ */
88
+ export function drawingGeometry(kind, request, now = Date.now()) {
89
+ if (!DRAWING_KINDS.includes(kind)) throw new Error(`画线类型必须为 ${DRAWING_KINDS.join('/')}`)
90
+ const latest = now + 365 * 86_400_000
91
+ const point = (time, price, label) => {
92
+ const t = Number(time), p = Number(price)
93
+ if (!Number.isFinite(t) || t < OLDEST || t > latest) throw new Error(`${label}时间必须为 2015 年至今后一年内的毫秒时间戳`)
94
+ if (!Number.isFinite(p) || p <= 0) throw new Error(`${label}价格必须为正数`)
95
+ return { time: t, price: p }
96
+ }
97
+ if (kind === 'level') {
98
+ const price = Number(request?.price)
99
+ if (!Number.isFinite(price) || price <= 0) throw new Error('水平线 price 必须为正数')
100
+ return { price }
101
+ }
102
+ if (kind === 'trend') {
103
+ const from = point(request?.fromTime, request?.fromPrice, '趋势线起点')
104
+ const to = point(request?.toTime, request?.toPrice, '趋势线终点')
105
+ if (to.time <= from.time) throw new Error('趋势线终点时间必须晚于起点')
106
+ return { fromTime: from.time, fromPrice: from.price, toTime: to.time, toPrice: to.price }
107
+ }
108
+ const need = DRAWING_POINTS[kind]
109
+ const raw = request?.points
110
+ if (kind === 'brush') {
111
+ if (!Array.isArray(raw) || raw.length < BRUSH_MIN || raw.length > BRUSH_MAX) throw new Error(`brush 需要 ${BRUSH_MIN}-${BRUSH_MAX} 个 points`)
112
+ } else if (!Array.isArray(raw) || raw.length !== need) throw new Error(`${kind} 需要 ${need} 个 points({ time, price })`)
113
+ const points = raw.map((item, index) => point(item?.time, item?.price, `第 ${index + 1} 个点`))
114
+ if (['trend', 'ray', 'extended', 'channel', 'fibchannel', 'gannfan', 'regression', 'fibtime', 'infoline', 'trendangle'].includes(kind) && points[0].time === points[1].time) throw new Error('前两个点的时间不能相同')
115
+ if ((kind === 'regression' || kind === 'fibtime' || kind === 'gannfan') && points[1].time < points[0].time) throw new Error('第二个点必须晚于第一个点')
116
+ if (kind === 'long' || kind === 'short') {
117
+ const [entry, stop, target] = points.map((item) => item.price)
118
+ const ok = kind === 'long' ? stop < entry && entry < target : target < entry && entry < stop
119
+ if (!ok) throw new Error(kind === 'long' ? '多头头寸需要 止损 < 入场 < 止盈' : '空头头寸需要 止盈 < 入场 < 止损')
120
+ if (points[1].time <= points[0].time) throw new Error('头寸右边界时间必须晚于入场时间')
121
+ }
122
+ return { points }
123
+ }
124
+
125
+ const COLOR = /^#[0-9a-fA-F]{6}$/
126
+ export const LINE_STYLES = ['solid', 'dashed', 'dotted']
127
+
128
+ /** Validate optional appearance fields; absent keys are left untouched. */
129
+ export function drawingStyle(request) {
130
+ const style = {}
131
+ if (request?.color != null) {
132
+ if (typeof request.color !== 'string' || !COLOR.test(request.color)) throw new Error('画线颜色必须为 #RRGGBB')
133
+ style.color = request.color.toLowerCase()
134
+ }
135
+ if (request?.lineWidth != null) {
136
+ const width = Number(request.lineWidth)
137
+ if (!Number.isInteger(width) || width < 1 || width > 4) throw new Error('线宽必须为 1-4 的整数')
138
+ style.lineWidth = width
139
+ }
140
+ if (request?.lineStyle != null) {
141
+ if (!LINE_STYLES.includes(request.lineStyle)) throw new Error(`线型必须为 ${LINE_STYLES.join('/')}`)
142
+ style.lineStyle = request.lineStyle
143
+ }
144
+ for (const flag of ['shared', 'alert']) {
145
+ if (request?.[flag] == null) continue
146
+ if (typeof request[flag] !== 'boolean') throw new Error(`${flag} 必须为布尔值`)
147
+ style[flag] = request[flag]
148
+ }
149
+ if (style.alert && request?.kind != null && !ALERT_KINDS.includes(request.kind)) throw new Error(`只有 ${ALERT_KINDS.join('/')} 支持价格提醒`)
150
+ if (request?.hidden != null) {
151
+ if (typeof request.hidden !== 'boolean') throw new Error('hidden 必须为布尔值')
152
+ style.hidden = request.hidden
153
+ }
154
+ if (request?.locked != null) {
155
+ if (typeof request.locked !== 'boolean') throw new Error('locked 必须为布尔值')
156
+ style.locked = request.locked
157
+ }
158
+ return style
159
+ }
package/lib/indicators.js CHANGED
@@ -46,13 +46,18 @@ function closeTime(row, interval) {
46
46
  return minutes ? row.t + minutes * 60_000 - 1 : row.t
47
47
  }
48
48
 
49
- export function computeIndicators(candles, { now = Date.now(), interval } = {}) {
49
+ /** Valid, de-duplicated, ascending candles split into closed rows and the count of still-forming ones. */
50
+ export function closedCandles(candles, { now = Date.now(), interval } = {}) {
50
51
  const valid = (candles ?? []).map((c) => ({ t: Number(c.t), T: c.T, o: Number(c.o), h: Number(c.h), l: Number(c.l), c: Number(c.c), v: Number(c.v) }))
51
52
  .filter((c) => [c.t, c.o, c.h, c.l, c.c, c.v].every(Number.isFinite) && c.t > 0 && c.h >= c.l && c.c > 0)
52
53
  .sort((a, b) => a.t - b.t)
53
54
  const unique = [...new Map(valid.map((row) => [row.t, row])).values()]
54
55
  const rows = unique.filter((row) => closeTime(row, interval) < now)
55
- const excludedOpen = unique.length - rows.length
56
+ return { rows, excludedOpen: unique.length - rows.length, closeTime: (row) => closeTime(row, interval) }
57
+ }
58
+
59
+ export function computeIndicators(candles, { now = Date.now(), interval } = {}) {
60
+ const { rows, excludedOpen } = closedCandles(candles, { now, interval })
56
61
  if (rows.length < 50) return { ready: false, candlesUsed: rows.length, excludedOpen, reason: '至少需要 50 根已收盘的有效 K 线' }
57
62
  const close = rows.map((c) => c.c)
58
63
  const volume = rows.map((c) => c.v)
@@ -0,0 +1,21 @@
1
+ export type MaKind = 'sma' | 'ema' | 'smma' | 'wma'
2
+ export type MaSource = 'close' | 'open' | 'high' | 'low' | 'hl2' | 'hlc3' | 'ohlc4'
3
+ export interface MaLine {
4
+ id: string
5
+ kind: MaKind
6
+ period: number
7
+ source: MaSource
8
+ smoothing: { kind: MaKind; period: number } | null
9
+ color?: string
10
+ }
11
+ export interface MaCandle { o: number; h: number; l: number; c: number }
12
+ export declare const MA_KINDS: MaKind[]
13
+ export declare const MA_SOURCES: MaSource[]
14
+ export declare const MA_MAX_LINES: number
15
+ export declare const MA_MAX_PERIOD: number
16
+ export declare function normalizeMaLine(input: unknown): MaLine
17
+ export declare function normalizeMaLines(list: unknown): MaLine[]
18
+ export declare function maLabel(line: MaLine): string
19
+ export declare function movingAverage(values: Array<number | null>, kind: MaKind, period: number): Array<number | null>
20
+ export declare function maSeries(candles: MaCandle[], line: MaLine): { values: Array<number | null>; smoothed: Array<number | null> | null }
21
+ export declare function maLatest(candles: MaCandle[], lines: MaLine[]): Array<{ id: string; label: string; value: number | null; smoothed: number | null; line: MaLine }>
@@ -0,0 +1,133 @@
1
+ /**
2
+ * dsh-hyperliquid — configurable moving-average lines, shared by the host
3
+ * (hyper_market / hyper_chart) and the browser chart so both compute the
4
+ * exact same values.
5
+ *
6
+ * Definitions follow TradingView Pine:
7
+ * SMA ta.sma arithmetic mean of the last n values
8
+ * EMA ta.ema alpha = 2/(n+1), seeded with the SMA of the first n values
9
+ * SMMA ta.rma alpha = 1/n (Wilder / smoothed MA), seeded with the SMA
10
+ * WMA ta.wma linear weights n, n-1, …, 1 (newest heaviest)
11
+ * An optional smoothing applies a second MA to the first line, like the
12
+ * "Smoothing" setting of TradingView's moving-average indicators.
13
+ * @module lib/moving-average
14
+ */
15
+
16
+ export const MA_KINDS = ['sma', 'ema', 'smma', 'wma']
17
+ export const MA_SOURCES = ['close', 'open', 'high', 'low', 'hl2', 'hlc3', 'ohlc4']
18
+ export const MA_MAX_LINES = 8
19
+ export const MA_MAX_PERIOD = 500
20
+ const COLOR = /^#[0-9a-fA-F]{6}$/
21
+
22
+ /**
23
+ * Validate and canonicalize one line definition (throws on invalid input).
24
+ * @param {object} input - { kind, period, source?, smoothing?: { kind, period }, color? }
25
+ * @returns {{ id: string, kind: string, period: number, source: string, smoothing: { kind: string, period: number } | null, color?: string }}
26
+ */
27
+ export function normalizeMaLine(input) {
28
+ if (!input || typeof input !== 'object') throw new Error('均线定义无效')
29
+ const kind = String(input.kind ?? '').toLowerCase()
30
+ if (!MA_KINDS.includes(kind)) throw new Error(`均线类型必须为 ${MA_KINDS.join('/')}`)
31
+ const period = Number(input.period)
32
+ if (!Number.isInteger(period) || period < 1 || period > MA_MAX_PERIOD) throw new Error(`均线周期必须为 1-${MA_MAX_PERIOD} 的整数`)
33
+ const source = String(input.source ?? 'close').toLowerCase()
34
+ if (!MA_SOURCES.includes(source)) throw new Error(`均线价格源必须为 ${MA_SOURCES.join('/')}`)
35
+ let smoothing = null
36
+ if (input.smoothing != null) {
37
+ const sKind = String(input.smoothing.kind ?? '').toLowerCase()
38
+ const sPeriod = Number(input.smoothing.period)
39
+ if (!MA_KINDS.includes(sKind)) throw new Error(`平滑类型必须为 ${MA_KINDS.join('/')}`)
40
+ if (!Number.isInteger(sPeriod) || sPeriod < 1 || sPeriod > MA_MAX_PERIOD) throw new Error(`平滑周期必须为 1-${MA_MAX_PERIOD} 的整数`)
41
+ smoothing = { kind: sKind, period: sPeriod }
42
+ }
43
+ const id = `${kind}${period}${source === 'close' ? '' : '-' + source}${smoothing ? `-${smoothing.kind}${smoothing.period}` : ''}`
44
+ const line = { id, kind, period, source, smoothing }
45
+ if (input.color != null) {
46
+ if (typeof input.color !== 'string' || !COLOR.test(input.color)) throw new Error('均线颜色必须为 #RRGGBB')
47
+ line.color = input.color.toLowerCase()
48
+ }
49
+ return line
50
+ }
51
+
52
+ /** Validate a whole set: at most MA_MAX_LINES, de-duplicated by id (last definition wins its slot). */
53
+ export function normalizeMaLines(list) {
54
+ if (!Array.isArray(list)) throw new Error('均线列表无效')
55
+ const byId = new Map()
56
+ for (const item of list) { const line = normalizeMaLine(item); byId.set(line.id, line) }
57
+ if (byId.size > MA_MAX_LINES) throw new Error(`最多同时显示 ${MA_MAX_LINES} 条均线`)
58
+ return [...byId.values()]
59
+ }
60
+
61
+ /** "SMMA 7", "EMA 21 hl2", "SMA 20 · EMA 9". */
62
+ export function maLabel(line) {
63
+ return `${line.kind.toUpperCase()} ${line.period}${line.source === 'close' ? '' : ' ' + line.source}${line.smoothing ? ` · ${line.smoothing.kind.toUpperCase()} ${line.smoothing.period}` : ''}`
64
+ }
65
+
66
+ function sourceOf(candle, source) {
67
+ const o = Number(candle.o), h = Number(candle.h), l = Number(candle.l), c = Number(candle.c)
68
+ switch (source) {
69
+ case 'open': return o
70
+ case 'high': return h
71
+ case 'low': return l
72
+ case 'hl2': return (h + l) / 2
73
+ case 'hlc3': return (h + l + c) / 3
74
+ case 'ohlc4': return (o + h + l + c) / 4
75
+ default: return c
76
+ }
77
+ }
78
+
79
+ /**
80
+ * One MA over a series that may start with nulls (e.g. the input of a smoothing pass).
81
+ * @param {Array<number|null>} values
82
+ * @param {string} kind
83
+ * @param {number} period
84
+ * @returns {Array<number|null>} aligned with `values`; null until enough data.
85
+ */
86
+ export function movingAverage(values, kind, period) {
87
+ const out = new Array(values.length).fill(null)
88
+ const window = []
89
+ let prior = null
90
+ for (let i = 0; i < values.length; i += 1) {
91
+ const value = values[i]
92
+ if (value == null || !Number.isFinite(value)) { if (kind === 'sma' || kind === 'wma') window.length = 0; continue }
93
+ if (kind === 'sma' || kind === 'wma') {
94
+ window.push(value)
95
+ if (window.length > period) window.shift()
96
+ if (window.length < period) continue
97
+ if (kind === 'sma') out[i] = window.reduce((sum, v) => sum + v, 0) / period
98
+ else { let num = 0; for (let k = 0; k < period; k += 1) num += window[k] * (k + 1); out[i] = num / (period * (period + 1) / 2) }
99
+ continue
100
+ }
101
+ // ema / smma: seed with the SMA of the first `period` values, then recurse.
102
+ if (prior == null) {
103
+ window.push(value)
104
+ if (window.length < period) continue
105
+ prior = window.reduce((sum, v) => sum + v, 0) / period
106
+ } else {
107
+ const alpha = kind === 'ema' ? 2 / (period + 1) : 1 / period
108
+ prior = alpha * value + (1 - alpha) * prior
109
+ }
110
+ out[i] = prior
111
+ }
112
+ return out
113
+ }
114
+
115
+ /**
116
+ * Values of one normalized line over candles (ascending by time).
117
+ * @returns {{ values: Array<number|null>, smoothed: Array<number|null> | null }}
118
+ */
119
+ export function maSeries(candles, line) {
120
+ const input = candles.map((candle) => { const v = sourceOf(candle, line.source); return Number.isFinite(v) ? v : null })
121
+ const values = movingAverage(input, line.kind, line.period)
122
+ const smoothed = line.smoothing ? movingAverage(values, line.smoothing.kind, line.smoothing.period) : null
123
+ return { values, smoothed }
124
+ }
125
+
126
+ /** Latest defined value of each line (and its smoothing) — what the agent reads. */
127
+ export function maLatest(candles, lines) {
128
+ return lines.map((line) => {
129
+ const { values, smoothed } = maSeries(candles, line)
130
+ const last = (series) => { for (let i = series.length - 1; i >= 0; i -= 1) if (series[i] != null) return series[i]; return null }
131
+ return { id: line.id, label: maLabel(line), value: last(values), smoothed: smoothed ? last(smoothed) : null, line }
132
+ })
133
+ }