dsh-hyperliquid 0.0.1 → 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.
package/lib/hl.js ADDED
@@ -0,0 +1,372 @@
1
+ // lib/hl.js — Hyperliquid market-data layer, SDK-backed.
2
+ //
3
+ // Data plane (info + WS) runs on the community-standard TypeScript SDK
4
+ // `@nktkas/hyperliquid` (MIT): HttpTransport for /info requests and
5
+ // WebSocketTransport + SubscriptionClient for live channels. The SDK brings
6
+ // schema-validated requests, automatic reconnection and automatic
7
+ // re-subscription after reconnect — the three things the previous hand-rolled
8
+ // wire layer lacked.
9
+ //
10
+ // The execution plane (/exchange) stays on our own EIP-712 signing
11
+ // implementation (lib/signing.js), which is byte-for-byte validated against
12
+ // the official hyperliquid-python-sdk (see test/signing.test.mjs).
13
+ //
14
+ // Public surface (consumed by lib/remote.js): HyperliquidClient, keys, sigFigs.
15
+ // Snapshot keys and payload shapes are unchanged, so the remote layer and the
16
+ // web desk are source-compatible.
17
+
18
+ import { canonicalAction, signL1Action } from './signing.js'
19
+
20
+ const MAINNET = 'https://api.hyperliquid.xyz'
21
+ const TESTNET = 'https://api.hyperliquid-testnet.xyz'
22
+ const WS_MAINNET = 'wss://api.hyperliquid.xyz/ws'
23
+ const WS_TESTNET = 'wss://api.hyperliquid-testnet.xyz/ws'
24
+
25
+ function quietUnsubscribe(channel) {
26
+ try { Promise.resolve(channel?.unsubscribe()).catch(() => {}) }
27
+ catch { /* transport already closing */ }
28
+ }
29
+
30
+ /** Round to `maxDecimals` with at most `n` significant figures. */
31
+ export function sigFigs(value, n, maxDecimals) {
32
+ const num = Number(value)
33
+ if (!Number.isFinite(num) || num === 0) return '0'
34
+ const abs = Math.abs(num)
35
+ const digits = Math.floor(Math.log10(abs)) + 1
36
+ const decimals = Math.max(0, Math.min(maxDecimals, n - digits))
37
+ return num.toFixed(decimals)
38
+ }
39
+
40
+ async function postJson(url, body, timeoutMs = 10_000) {
41
+ const ac = new AbortController()
42
+ const timer = setTimeout(() => ac.abort(new Error('timeout')), timeoutMs)
43
+ try {
44
+ const res = await fetch(url, {
45
+ method: 'POST',
46
+ headers: { 'content-type': 'application/json' },
47
+ body: JSON.stringify(body),
48
+ signal: ac.signal,
49
+ })
50
+ const data = await res.json().catch(() => null)
51
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${JSON.stringify(data)?.slice(0, 300)}`)
52
+ return data
53
+ } finally {
54
+ clearTimeout(timer)
55
+ }
56
+ }
57
+
58
+ /**
59
+ * One Hyperliquid endpoint pair (REST + WS) with snapshot caches.
60
+ * The trading desk instantiates exactly one; clients poll snapshots from it.
61
+ *
62
+ * Live channels are ref-counted: remote.js calls subscribe(key)/unsubscribe(key)
63
+ * as coins gain/lose focus; every callback just refreshes the snapshot map.
64
+ */
65
+ export class HyperliquidClient {
66
+ /**
67
+ * @param {{network?: 'mainnet'|'testnet', privateKey?: string}} opts
68
+ */
69
+ constructor(opts = {}) {
70
+ this.network = opts.network === 'mainnet' ? 'mainnet' : 'testnet'
71
+ this.restBase = this.network === 'mainnet' ? MAINNET : TESTNET
72
+ this.wsBase = this.network === 'mainnet' ? WS_MAINNET : WS_TESTNET
73
+ this.privateKey = opts.privateKey ?? ''
74
+ // A signer must never reuse a nonce, including concurrent order/cancel calls.
75
+ this.lastNonce = 0
76
+
77
+ // ── SDK transports (lazy: nothing connects until first use) ──
78
+ this._sdk = null // { HttpTransport, WebSocketTransport, SubscriptionClient, info: {...fns} }
79
+ this._http = null
80
+ this._wsTransport = null
81
+ this._subsClient = null
82
+ this._liveSubs = new Map() // snapshot key -> ISubscription
83
+
84
+ this.ws = null // legacy compat field (unused by the SDK path)
85
+ this.wsState = 'closed' // closed | idle | connecting | open
86
+ this.subs = new Map() // key -> {args, listeners:number}
87
+ this.snapshots = new Map() // key -> latest payload
88
+ this.snapshotTimes = new Map() // key -> last exchange message received on host
89
+ this.seqs = new Map() // key -> monotonically increasing revision
90
+ this.throws = 0
91
+ this.lastWsError = null
92
+ this.pingTimer = null // legacy compat (SDK keep-alive handles ping)
93
+ this.retryTimers = new Map() // failed channel key -> retry timer
94
+ }
95
+
96
+ // ── SDK bootstrap ────────────────────────────────────────────────────
97
+ _sdkOnce() {
98
+ if (this._sdk) return this._sdk
99
+ // Plain dynamic imports keep the SDK optional at parse time and let the
100
+ // desk degrade to REST-only polling if the package is unavailable.
101
+ const root = '@nktkas/hyperliquid'
102
+ const infoApi = `${root}/api/info`
103
+ const subApi = `${root}/api/subscription`
104
+ // Deliberately not awaited: transports are created eagerly-ish but callers
105
+ // await the individual request methods, which await their modules.
106
+ this._sdk = Promise.all([import(root), import(infoApi), import(subApi)]).then(([r, i, s]) => {
107
+ this._http = new r.HttpTransport({ isTestnet: this.network !== 'mainnet' })
108
+ const cfg = { transport: this._http }
109
+ this._sdkInfo = {
110
+ meta: () => i.meta(cfg),
111
+ metaAndAssetCtxs: () => i.metaAndAssetCtxs(cfg),
112
+ allMids: () => i.allMids(cfg),
113
+ l2Book: (coin, grouping = null) => i.l2Book(cfg, { coin, ...(grouping ?? {}) }),
114
+ candleSnapshot: (coin, interval, startTime, endTime) => i.candleSnapshot(cfg, { coin, interval, startTime, endTime }),
115
+ clearinghouseState: (user) => i.clearinghouseState(cfg, { user }),
116
+ spotClearinghouseState: (user) => i.spotClearinghouseState(cfg, { user }),
117
+ userAbstraction: (user) => i.userAbstraction(cfg, { user }),
118
+ activeAssetData: (user, coin) => i.activeAssetData(cfg, { user, coin }),
119
+ openOrders: (user) => i.openOrders(cfg, { user }),
120
+ frontendOpenOrders: (user) => i.frontendOpenOrders(cfg, { user }),
121
+ userFills: (user) => i.userFills(cfg, { user }),
122
+ historicalOrders: (user) => i.historicalOrders(cfg, { user }),
123
+ portfolio: (user) => i.portfolio(cfg, { user }),
124
+ }
125
+ this._wsTransport = new r.WebSocketTransport({ url: this.wsBase })
126
+ this._subsClient = new r.SubscriptionClient({ transport: this._wsTransport })
127
+ // Connection bookkeeping: the transport's event target carries the
128
+ // system events; ready() resolves once the socket is up.
129
+ const hlEvents = this._wsTransport._hlEvents
130
+ hlEvents?.addEventListener?.('error', (ev) => {
131
+ this.lastWsError = ev?.detail ?? 'ws error'
132
+ this.wsState = 'connecting'
133
+ })
134
+ hlEvents?.addEventListener?.('subscriptionResponse', () => {
135
+ // Unsubscribe acks arrive here too; with no channels left we are idle.
136
+ if (this.wsState !== 'closed') this.wsState = this.subs.size ? 'open' : 'idle'
137
+ })
138
+ // The SDK socket reconnects and resubscribes on its own; mirror that so
139
+ // a dropped upstream reads as "connecting" rather than a silent "open".
140
+ const socket = this._wsTransport.socket
141
+ socket?.addEventListener?.('close', () => { if (this.wsState !== 'closed') this.wsState = 'connecting' })
142
+ socket?.addEventListener?.('open', () => { if (this.wsState === 'connecting') this.wsState = this.subs.size ? 'open' : 'idle' })
143
+ Promise.resolve(this._wsTransport.ready?.()).then(() => {
144
+ this.wsState = 'open'
145
+ }).catch((err) => {
146
+ this.lastWsError = String(err?.message ?? err)
147
+ })
148
+ return this._sdkInfo
149
+ })
150
+ return this._sdk
151
+ }
152
+
153
+ _infoFns() {
154
+ return this._sdkOnce().then((fns) => {
155
+ if (!fns) throw new Error('SDK 初始化失败(@nktkas/hyperliquid 不可用)')
156
+ return fns
157
+ })
158
+ }
159
+
160
+ // ── /info ────────────────────────────────────────────────────────────
161
+ /** Generic /info passthrough with a timeout. `type` values per docs. */
162
+ info(type, extra = {}, timeoutMs = 10_000) {
163
+ return postJson(`${this.restBase}/info`, { type, ...extra }, timeoutMs)
164
+ }
165
+
166
+ async meta() { return (await this._infoFns()).meta() }
167
+ async metaAndAssetCtxs() { return (await this._infoFns()).metaAndAssetCtxs() }
168
+ async allMids() { return (await this._infoFns()).allMids() }
169
+ async l2Book(coin, grouping = null) { return (await this._infoFns()).l2Book(coin, grouping) }
170
+ recentTrades(coin) { return this.info('recentTrades', { coin }) }
171
+ async candleSnapshot(coin, interval, startTime, endTime) {
172
+ return (await this._infoFns()).candleSnapshot(coin, interval, startTime, endTime)
173
+ }
174
+ async clearinghouseState(user) { return (await this._infoFns()).clearinghouseState(user) }
175
+ async spotClearinghouseState(user) { return (await this._infoFns()).spotClearinghouseState(user) }
176
+ async userAbstraction(user) { return (await this._infoFns()).userAbstraction(user) }
177
+ async activeAssetData(user, coin) { return (await this._infoFns()).activeAssetData(user, coin) }
178
+ async openOrders(user) { return (await this._infoFns()).openOrders(user) }
179
+ async frontendOpenOrders(user) { return (await this._infoFns()).frontendOpenOrders(user) }
180
+ async userFills(user) { return (await this._infoFns()).userFills(user) }
181
+ async historicalOrders(user) { return (await this._infoFns()).historicalOrders(user) }
182
+ async portfolio(user) { return (await this._infoFns()).portfolio(user) }
183
+
184
+ // ── /exchange ────────────────────────────────────────────────────────
185
+ /**
186
+ * Send a signed exchange action. Paper mode never reaches here.
187
+ * Execution stays on our validated EIP-712 path (lib/signing.js).
188
+ * @param {object} action L1 action body, e.g. {type:'order', orders:[…], grouping:'na'}
189
+ */
190
+ async exchange(action) {
191
+ if (!this.privateKey) throw new Error('未配置钱包私钥(插件 config.privateKey);paper 模式下单不经过 /exchange')
192
+ const nonce = this.nextNonce()
193
+ const canonical = canonicalAction(action)
194
+ const signature = await signL1Action(this.privateKey, canonical, null, nonce, this.network === 'mainnet')
195
+ return postJson(`${this.restBase}/exchange`, { action: canonical, nonce, signature }, 15_000)
196
+ }
197
+
198
+ nextNonce() {
199
+ this.lastNonce = Math.max(Date.now(), this.lastNonce + 1)
200
+ return this.lastNonce
201
+ }
202
+
203
+ /**
204
+ * Send an exchange action with an EXTERNALLY produced signature — the
205
+ * dsh-web3 path: the private key never leaves the wallet vault, this
206
+ * client only transports the already-signed envelope.
207
+ * @param {object} action L1 action body
208
+ * @param {{r: string, s: string, v: number}} signature wire-form signature
209
+ * @param {number} nonce the nonce that was hashed into the signature
210
+ */
211
+ exchangeSigned(action, signature, nonce) {
212
+ if (!signature?.r || !signature?.s || !signature?.v) throw new Error('exchangeSigned 需要完整的 {r,s,v} 签名')
213
+ return postJson(`${this.restBase}/exchange`, { action: canonicalAction(action), nonce, signature }, 15_000)
214
+ }
215
+
216
+ // ── WebSocket upstream (SDK SubscriptionClient) ──────────────────────
217
+ /** Map a snapshot key to a SubscriptionClient channel; resolve its ISubscription. */
218
+ async _openChannel(key, args) {
219
+ await this._infoFns()
220
+ this.wsState = 'connecting'
221
+ const sc = this._subsClient
222
+ const feed = (payload) => {
223
+ this.snapshots.set(key, payload)
224
+ this.snapshotTimes.set(key, Date.now())
225
+ this.seqs.set(key, (this.seqs.get(key) ?? 0) + 1)
226
+ this.wsState = 'open'
227
+ }
228
+ switch (args?.type) {
229
+ case 'allMids':
230
+ return sc.allMids((ev) => feed(ev?.detail ?? ev))
231
+ case 'allDexsAssetCtxs':
232
+ return sc.allDexsAssetCtxs((ev) => feed(ev?.detail ?? ev))
233
+ case 'allDexsClearinghouseState':
234
+ return sc.allDexsClearinghouseState({ user: args.user }, (ev) => feed(ev?.detail ?? ev))
235
+ case 'l2Book':
236
+ return sc.l2Book({ coin: args.coin, ...(args.nSigFigs ? { nSigFigs: args.nSigFigs, ...(args.mantissa ? { mantissa: args.mantissa } : {}) } : {}) }, (ev) => feed(ev?.detail ?? ev))
237
+ case 'trades':
238
+ return sc.trades({ coin: args.coin }, (ev) => feed(ev?.detail ?? ev))
239
+ case 'candle':
240
+ return sc.candle({ coin: args.coin, interval: args.interval }, (ev) => feed(ev?.detail ?? ev))
241
+ case 'userFills':
242
+ return sc.userFills({ user: args.user }, (ev) => feed(ev?.detail ?? ev))
243
+ case 'userEvents':
244
+ return sc.userEvents({ user: args.user }, (ev) => feed(ev?.detail ?? ev))
245
+ default:
246
+ throw new Error(`unknown subscription type: ${args?.type}`)
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Register interest in a WS channel; ref-counted. Returns the snapshot key.
252
+ * Supported: allMids | allDexsAssetCtxs | {allDexsClearinghouseState, user} | {l2Book, coin} | {trades, coin} | {candle, coin, interval}
253
+ * | {userFills, user} | {userEvents, user}
254
+ */
255
+ subscribe(key, args) {
256
+ const row = this.subs.get(key) ?? { args, listeners: 0, channel: null, opening: false }
257
+ row.listeners += 1
258
+ this.subs.set(key, row)
259
+ if (!row.channel && !row.opening) {
260
+ row.opening = true
261
+ this._openChannel(key, args)
262
+ .then((sub) => {
263
+ row.channel = sub
264
+ row.opening = false
265
+ this.wsState = 'open'
266
+ if (row.listeners <= 0) {
267
+ // Interest went away while the channel was opening.
268
+ quietUnsubscribe(sub)
269
+ row.channel = null
270
+ this.subs.delete(key)
271
+ this.snapshots.delete(key)
272
+ this.snapshotTimes.delete(key)
273
+ }
274
+ })
275
+ .catch((err) => {
276
+ row.opening = false
277
+ this.lastWsError = String(err?.message ?? err)
278
+ if (row.listeners > 0) this._retryChannel(key)
279
+ })
280
+ }
281
+ return key
282
+ }
283
+
284
+ _retryChannel(key) {
285
+ if (this.retryTimers.has(key)) return
286
+ const timer = setTimeout(() => {
287
+ this.retryTimers.delete(key)
288
+ const row = this.subs.get(key)
289
+ if (row && row.listeners > 0 && !row.channel && !row.opening) {
290
+ row.opening = true
291
+ this._openChannel(key, row.args)
292
+ .then((sub) => {
293
+ row.opening = false
294
+ if (row.listeners <= 0) { quietUnsubscribe(sub); this.subs.delete(key); return }
295
+ row.channel = sub
296
+ this.wsState = 'open'
297
+ })
298
+ .catch((err) => {
299
+ row.opening = false
300
+ this.lastWsError = String(err?.message ?? err)
301
+ if (row.listeners > 0) this._retryChannel(key)
302
+ })
303
+ }
304
+ }, 3_000)
305
+ this.retryTimers.set(key, timer)
306
+ }
307
+
308
+ unsubscribe(key) {
309
+ const row = this.subs.get(key)
310
+ if (!row) return
311
+ row.listeners -= 1
312
+ if (row.listeners <= 0) {
313
+ if (row.channel) {
314
+ quietUnsubscribe(row.channel)
315
+ row.channel = null
316
+ }
317
+ this.subs.delete(key)
318
+ this.snapshots.delete(key)
319
+ this.snapshotTimes.delete(key)
320
+ const timer = this.retryTimers.get(key)
321
+ if (timer) clearTimeout(timer)
322
+ this.retryTimers.delete(key)
323
+ // The upstream socket stays connected (SDK keep-alive); only channels went away.
324
+ if (this.subs.size === 0 && this.wsState !== 'closed') this.wsState = 'idle'
325
+ }
326
+ }
327
+
328
+ snapshot(key) {
329
+ const snap = this.snapshots.get(key)
330
+ const seq = this.seqs.get(key) ?? 0
331
+ return snap === undefined ? null : { seq, at: this.snapshotTimes.get(key) ?? 0, data: snap }
332
+ }
333
+
334
+ wsStatus() {
335
+ const lastDataAt = Math.max(0, ...this.snapshotTimes.values())
336
+ const state = this.wsState === 'open' && lastDataAt > 0 && Date.now() - lastDataAt > 15_000
337
+ ? 'stale' : this.wsState
338
+ return { state, lastDataAt, subs: [...this.subs.keys()], lastError: this.lastWsError }
339
+ }
340
+
341
+ close() {
342
+ for (const timer of this.retryTimers.values()) clearTimeout(timer)
343
+ this.retryTimers.clear()
344
+ for (const [key, row] of this.subs) {
345
+ if (row.channel) {
346
+ quietUnsubscribe(row.channel)
347
+ row.channel = null
348
+ }
349
+ row.listeners = 0
350
+ }
351
+ this.subs.clear()
352
+ this.snapshots.clear()
353
+ this.snapshotTimes.clear()
354
+ try { this._wsTransport?.close?.() } catch { /* already closed */ }
355
+ this.wsState = 'closed'
356
+ this.ws = null
357
+ }
358
+ }
359
+
360
+ /** Canonical snapshot key builders shared with the remote layer. */
361
+ export const keys = {
362
+ allMids: () => 'allMids',
363
+ allDexsAssetCtxs: () => 'allDexsAssetCtxs',
364
+ allDexsClearinghouseState: (user) => `allDexsClearinghouseState:${user.toLowerCase()}`,
365
+ l2Book: (coin, grouping = null) => grouping
366
+ ? `l2BookGrouped:${encodeURIComponent(coin)}:${grouping.nSigFigs}:${grouping.mantissa ?? 0}`
367
+ : `l2Book:${coin}`,
368
+ trades: (coin) => `trades:${coin}`,
369
+ candle: (coin, interval) => `candle:${coin}:${interval}`,
370
+ userEvents: () => 'userEvents',
371
+ userFills: (user) => `userFills:${user}`,
372
+ }
@@ -0,0 +1,93 @@
1
+ /** Deterministic indicators over exchange candles; no price prediction. */
2
+
3
+ function sma(values, period) {
4
+ if (values.length < period) return null
5
+ return values.slice(-period).reduce((sum, value) => sum + value, 0) / period
6
+ }
7
+
8
+ function emaSeries(values, period) {
9
+ if (values.length < period) return []
10
+ const alpha = 2 / (period + 1)
11
+ let value = values.slice(0, period).reduce((sum, point) => sum + point, 0) / period
12
+ const result = [value]
13
+ for (const point of values.slice(period)) {
14
+ value = point * alpha + value * (1 - alpha)
15
+ result.push(value)
16
+ }
17
+ return result
18
+ }
19
+
20
+ function wilder(values, period) {
21
+ if (values.length < period) return null
22
+ let result = values.slice(0, period).reduce((sum, value) => sum + value, 0) / period
23
+ for (const value of values.slice(period)) result = (result * (period - 1) + value) / period
24
+ return result
25
+ }
26
+
27
+ function swings(rows, kind) {
28
+ const picks = []
29
+ for (let i = 2; i < rows.length - 2; i += 1) {
30
+ const field = kind === 'low' ? 'l' : 'h'
31
+ const value = rows[i][field]
32
+ const neighbors = [rows[i - 2][field], rows[i - 1][field], rows[i + 1][field], rows[i + 2][field]]
33
+ if (neighbors.every((point) => kind === 'low' ? value <= point : value >= point)) picks.push({ price: value, time: rows[i].t })
34
+ }
35
+ return picks
36
+ }
37
+
38
+ function closeTime(row, interval) {
39
+ if (Number.isFinite(Number(row.T)) && Number(row.T) >= row.t) return Number(row.T)
40
+ if (interval === '1M') {
41
+ const opened = new Date(row.t)
42
+ return Date.UTC(opened.getUTCFullYear(), opened.getUTCMonth() + 1, 1) - 1
43
+ }
44
+ const minutes = { '1m': 1, '3m': 3, '5m': 5, '15m': 15, '30m': 30, '1h': 60, '2h': 120,
45
+ '4h': 240, '8h': 480, '12h': 720, '1d': 1440, '3d': 4320, '1w': 10080 }[interval]
46
+ return minutes ? row.t + minutes * 60_000 - 1 : row.t
47
+ }
48
+
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 } = {}) {
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) }))
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)
53
+ .sort((a, b) => a.t - b.t)
54
+ const unique = [...new Map(valid.map((row) => [row.t, row])).values()]
55
+ const rows = unique.filter((row) => closeTime(row, interval) < now)
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 })
61
+ if (rows.length < 50) return { ready: false, candlesUsed: rows.length, excludedOpen, reason: '至少需要 50 根已收盘的有效 K 线' }
62
+ const close = rows.map((c) => c.c)
63
+ const volume = rows.map((c) => c.v)
64
+ const ema12 = emaSeries(close, 12)
65
+ const ema26 = emaSeries(close, 26)
66
+ const macdLine = ema12.slice(14).map((value, i) => value - ema26[i])
67
+ const signal = emaSeries(macdLine, 9)
68
+ const deltas = close.slice(1).map((value, i) => value - close[i])
69
+ const avgGain = wilder(deltas.map((value) => Math.max(value, 0)), 14)
70
+ const avgLoss = wilder(deltas.map((value) => Math.max(-value, 0)), 14)
71
+ const rsi = avgLoss === 0 ? (avgGain === 0 ? 50 : 100) : 100 - 100 / (1 + avgGain / avgLoss)
72
+ const trueRanges = rows.slice(1).map((c, i) => Math.max(c.h - c.l, Math.abs(c.h - rows[i].c), Math.abs(c.l - rows[i].c)))
73
+ const atr14 = wilder(trueRanges, 14)
74
+ const close20 = close.slice(-20)
75
+ const mean20 = sma(close, 20)
76
+ const deviation = Math.sqrt(close20.reduce((sum, value) => sum + (value - mean20) ** 2, 0) / 20)
77
+ const latest = rows.at(-1)
78
+ const recent = rows.slice(-100)
79
+ const supportFallback = rows.slice(-20).reduce((best, row) => row.l < best.l ? row : best)
80
+ const resistanceFallback = rows.slice(-20).reduce((best, row) => row.h > best.h ? row : best)
81
+ const support = swings(recent, 'low').filter((point) => point.price < latest.c).sort((a, b) => b.price - a.price)[0]
82
+ ?? { price: supportFallback.l, time: supportFallback.t }
83
+ const resistance = swings(recent, 'high').filter((point) => point.price > latest.c).sort((a, b) => a.price - b.price)[0]
84
+ ?? { price: resistanceFallback.h, time: resistanceFallback.t }
85
+ return {
86
+ ready: true, candlesUsed: rows.length, excludedOpen, observedAt: closeTime(latest, interval), candleOpenedAt: latest.t, close: latest.c,
87
+ sma20: mean20, ema20: emaSeries(close, 20).at(-1), ema50: emaSeries(close, 50).at(-1),
88
+ rsi14: rsi, macd: { line: macdLine.at(-1), signal: signal.at(-1), histogram: macdLine.at(-1) - signal.at(-1) },
89
+ bollinger20: { lower: mean20 - 2 * deviation, middle: mean20, upper: mean20 + 2 * deviation },
90
+ atr14, volumeRatio20: latest.v / (sma(volume.slice(0, -1), 20) || 1),
91
+ support, resistance,
92
+ }
93
+ }
@@ -0,0 +1,5 @@
1
+ /** Known legacy/display names that are not valid Hyperliquid API coin names. */
2
+ export function canonicalMarketCoin(value) {
3
+ const coin = String(value ?? '').trim()
4
+ return coin === 'xyz:100' || coin === 'XYZ100' ? 'xyz:XYZ100' : coin
5
+ }
@@ -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
+ }