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/NOTICE +30 -0
- package/README.md +128 -19
- package/client.js +20828 -0
- package/cordis.patch.yml +7 -0
- package/icon.svg +7 -0
- package/index.js +118 -0
- package/lib/chart-drawings.d.ts +23 -0
- package/lib/chart-drawings.js +159 -0
- package/lib/hl.js +372 -0
- package/lib/indicators.js +93 -0
- package/lib/market-symbol.js +5 -0
- package/lib/moving-average.d.ts +21 -0
- package/lib/moving-average.js +133 -0
- package/lib/msgpack.js +123 -0
- package/lib/remote.js +1494 -0
- package/lib/risk-settings.js +20 -0
- package/lib/signing.js +215 -0
- package/lib/skill.js +19 -0
- package/lib/store.js +416 -0
- package/lib/tools.js +472 -0
- package/lib/trading-guard.js +86 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +76 -9
- package/skills/hyperliquid-trading/SKILL.md +44 -0
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,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
|
+
}
|