dsh-hyperliquid 0.0.1 → 0.1.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,7 @@
1
+ - insert:
2
+ - id: hyperliquid
3
+ name: dsh-hyperliquid
4
+ config:
5
+ dataDir: '~/.dsh-hyperliquid'
6
+ network: mainnet
7
+ tradingMode: live
package/icon.svg ADDED
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
2
+ <!-- Official Hyperliquid mark (brand kit "Hyperliquid SVG format.zip",
3
+ Hyperliquid_Blob_Green.svg) on the official brand-dark plate.
4
+ Source: https://hyperliquid.gitbook.io/hyperliquid-docs/brand-kit -->
5
+ <rect width="200" height="200" rx="44" fill="#011916"/>
6
+ <path d="M175.11589,99.27891c0,49.46366-30.3687,65.33789-46.47302,51.30413-13.11357-11.5031-17.02471-35.88989-36.81018-38.42055-25.07698-2.99085-27.37764,30.36839-43.94216,30.36839-19.3254,0-23.00642-27.83773-23.00642-42.33179,0-14.72405,4.14115-34.73961,20.47565-34.73961,19.09528,0,20.24566,28.75802,44.17226,27.14754,23.69661-1.61047,24.1567-31.51877,39.80103-44.17224,13.57335-11.27313,45.78283.69019,45.78283,50.84413Z" fill="#97fce4"/>
7
+ </svg>
package/index.js ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * dsh-hyperliquid — host half.
3
+ *
4
+ * Publishes the `hyper` Remote namespace (Typert, dsh-bots-anchored), registers
5
+ * the hyper_* agent tools, and contributes a short system-prompt section so
6
+ * agents in any session know the trading desk exists and how to drive it.
7
+ * The client half (client.js) renders the peer-level trading platform.
8
+ *
9
+ * @module dsh-hyperliquid
10
+ */
11
+
12
+ import { HyperRemote } from './lib/remote.js'
13
+ import { buildToolDefinitions } from './lib/tools.js'
14
+ import { registerHyperSkill } from './lib/skill.js'
15
+
16
+ export const name = 'dsh-hyperliquid'
17
+
18
+ /** Optional services only: without `tools` the desk still serves the UI. */
19
+ export const inject = []
20
+
21
+ let z = null
22
+ try {
23
+ // Prefer the HOST's schemastery copy (same implementation the loader
24
+ // validates with) resolved through the dsh-bin anchor; fall back to the
25
+ // bundled copy. require(esm) — Node ≥ 22.12.
26
+ const { createRequire } = await import('node:module')
27
+ const { realpathSync } = await import('node:fs')
28
+ try {
29
+ const anchor = realpathSync(process.argv?.[1] ?? '')
30
+ z = createRequire(anchor)('@deepseek-ai/schemastery')
31
+ if (!z || typeof z.object !== 'function') z = null
32
+ } catch { /* anchor unavailable → bundled copy */ }
33
+ if (!z) z = (await import('@deepseek-ai/schemastery')).default
34
+ } catch (err) {
35
+ throw new Error(`[dsh-hyperliquid] schemastery 加载失败:${String(err?.message ?? err)}`)
36
+ }
37
+
38
+ export const Config = z.object({
39
+ dataDir: z.string().description('插件数据目录(交易会话登记表与 paper 账户落盘处)').default('~/.dsh-hyperliquid'),
40
+ network: z.union([z.const('mainnet'), z.const('testnet')]).description('Hyperliquid 网络,默认 mainnet;testnet 可在交易台「更多」中切换').default('mainnet'),
41
+ tradingMode: z.union([z.const('paper'), z.const('live')]).description('交易模式:live 真实签名下单(默认),paper 本地撮合;可在交易台「更多」中切换').default('live'),
42
+ privateKey: z.string().description('Hyperliquid 钱包私钥(signer=local 时使用;建议用 agent wallet 派生密钥;留空则 live 不可用)。也可用环境变量 HYPERLIQUID_PRIVATE_KEY').default(''),
43
+ signer: z.union([z.const('local'), z.const('web3')]).description('签名方式:local 插件内私钥签名;web3 交给 dsh-web3 钱包保险库签名(私钥不出保险库,逐笔过签名策略与审批)').default('local'),
44
+ web3Account: z.string().description('signer=web3 时使用的 dsh-web3 账户 id 或名称').default('main'),
45
+ tradingAddress: z.string().description('Hyperliquid 主账户地址;使用 dsh-web3 API wallet 签名时必填,账户查询必须用主账户地址').default(''),
46
+ autonomousLive: z.boolean().description('允许 agent 在硬性限额内自主提交 live 订单;默认关闭,且仅支持 dsh-web3 签名').default(false),
47
+ allowedCoins: z.string().description('交易限额(paper/live 均生效):允许新开仓的币对,逗号分隔;减仓不受限').default('BTC,ETH'),
48
+ maxOrderUsd: z.number().description('交易限额(paper/live 均生效):单笔名义价值上限 USD').default(100),
49
+ maxDailyUsd: z.number().description('交易限额(paper/live 均生效):每 UTC 日累计开仓名义价值上限 USD,paper 与 live 分账').default(500),
50
+ maxPositionUsd: z.number().description('交易限额(paper/live 均生效):每币最大仓位名义价值 USD').default(500),
51
+ maxLeverage: z.number().description('交易限额(paper/live 均生效):最大杠杆倍数').default(2),
52
+ }).description('Hyperliquid 交易台配置')
53
+
54
+ /**
55
+ * @param ctx - the Cordis plugin context.
56
+ * @param config - validated row config.
57
+ */
58
+ export function apply(ctx, config = {}) {
59
+ const remote = new HyperRemote(ctx, config)
60
+ const disposers = []
61
+
62
+ // Live channel lifecycle: stop the upstream WS on teardown.
63
+ ctx.effect(() => () => remote.stopWs())
64
+
65
+ // Agent tools (optional service — degrade gracefully).
66
+ const tools = typeof ctx?.get === 'function' ? ctx.get('tools') : undefined
67
+ if (tools && typeof tools.register === 'function') {
68
+ for (const def of buildToolDefinitions(remote)) {
69
+ const remove = tools.register(def)
70
+ disposers.push(remove)
71
+ ctx.effect(() => () => { try { remove() } catch { /* already gone */ } })
72
+ }
73
+ } else {
74
+ ctx.logger?.warn?.('[dsh-hyperliquid] tools 服务不可用,仅提供 UI 远端')
75
+ }
76
+
77
+ const skillRemove = registerHyperSkill(typeof ctx?.get === 'function' ? ctx.get('skills') : null)
78
+ if (skillRemove) {
79
+ disposers.push(skillRemove)
80
+ ctx.effect(() => () => { try { skillRemove() } catch { /* already gone */ } })
81
+ }
82
+
83
+ // System-prompt section: make the desk discoverable to every agent, with
84
+ // LIVE desk context (what the user is looking at right now) evaluated per
85
+ // prompt — the desk and the conversation stay in lockstep both ways.
86
+ const systemPrompt = typeof ctx?.get === 'function' ? ctx.get('systemPrompt') : undefined
87
+ if (systemPrompt && typeof systemPrompt.section === 'function') {
88
+ const remove = systemPrompt.section({
89
+ name: 'hyperliquid-desk',
90
+ order: 500,
91
+ text: () => {
92
+ const mode = remote.cfg.tradingMode
93
+ const net = remote.cfg.network
94
+ const lines = [
95
+ '## Hyperliquid 交易台(dsh-hyperliquid)',
96
+ `本 Harness 装有 Hyperliquid 交易台(mode=${mode},network=${net})。用户可在面板看行情并填写交易票据;票据只把意图送入当前会话,所有实际下单只能由你调用工具完成。`,
97
+ '可用工具:hyper_context(会话交易上下文)、hyper_market(详细行情)、hyper_chart(在行情图上画支撑/阻力与趋势线)、hyper_account(账户)、hyper_decide(交易决策预检并返回一次性 decisionId)、hyper_order(执行/撤单/杠杆)、hyper_session(会话视角)。',
98
+ '用户说"当前/界面上这个币"时,先 hyper_context 或 hyper_session status;需要技术判断时用 hyper_market indicators 验证趋势、动量、波动和支撑阻力,可用 hyper_chart 将依据画给用户看。交易前核对行情与账户,说明方向、数量、依据及风险。paper 模式用 hyper_order 执行;live 模式先用 hyper_decide 预检,并用返回的一次性 decisionId 执行 hyper_order。是否可自主执行由宿主配置和钱包策略决定。',
99
+ ]
100
+ if (skillRemove) lines.push('复杂的 Hyperliquid 分析、交易或异常对账,请先加载 hyperliquid-trading skill。')
101
+ // Live desk focus: what coin/interval the desk shows right now.
102
+ try {
103
+ const desk = remote.desk ?? {}
104
+ if (desk.coin) lines.push(`当前交易台焦点:${desk.coin} · K线 ${desk.interval ?? '15m'}。用户消息里说"这个币/当前价格/图上"时指的就是它。`)
105
+ } catch { /* desk context is best-effort */ }
106
+ return lines.join('\n')
107
+ },
108
+ })
109
+ disposers.push(remove)
110
+ }
111
+
112
+ return () => {
113
+ for (const d of disposers) {
114
+ try { d() } catch { /* best effort */ }
115
+ }
116
+ remote.stopWs()
117
+ }
118
+ }
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,88 @@
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
+ export function computeIndicators(candles, { now = Date.now(), interval } = {}) {
50
+ 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
+ .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
+ .sort((a, b) => a.t - b.t)
53
+ const unique = [...new Map(valid.map((row) => [row.t, row])).values()]
54
+ const rows = unique.filter((row) => closeTime(row, interval) < now)
55
+ const excludedOpen = unique.length - rows.length
56
+ if (rows.length < 50) return { ready: false, candlesUsed: rows.length, excludedOpen, reason: '至少需要 50 根已收盘的有效 K 线' }
57
+ const close = rows.map((c) => c.c)
58
+ const volume = rows.map((c) => c.v)
59
+ const ema12 = emaSeries(close, 12)
60
+ const ema26 = emaSeries(close, 26)
61
+ const macdLine = ema12.slice(14).map((value, i) => value - ema26[i])
62
+ const signal = emaSeries(macdLine, 9)
63
+ const deltas = close.slice(1).map((value, i) => value - close[i])
64
+ const avgGain = wilder(deltas.map((value) => Math.max(value, 0)), 14)
65
+ const avgLoss = wilder(deltas.map((value) => Math.max(-value, 0)), 14)
66
+ const rsi = avgLoss === 0 ? (avgGain === 0 ? 50 : 100) : 100 - 100 / (1 + avgGain / avgLoss)
67
+ 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)))
68
+ const atr14 = wilder(trueRanges, 14)
69
+ const close20 = close.slice(-20)
70
+ const mean20 = sma(close, 20)
71
+ const deviation = Math.sqrt(close20.reduce((sum, value) => sum + (value - mean20) ** 2, 0) / 20)
72
+ const latest = rows.at(-1)
73
+ const recent = rows.slice(-100)
74
+ const supportFallback = rows.slice(-20).reduce((best, row) => row.l < best.l ? row : best)
75
+ const resistanceFallback = rows.slice(-20).reduce((best, row) => row.h > best.h ? row : best)
76
+ const support = swings(recent, 'low').filter((point) => point.price < latest.c).sort((a, b) => b.price - a.price)[0]
77
+ ?? { price: supportFallback.l, time: supportFallback.t }
78
+ const resistance = swings(recent, 'high').filter((point) => point.price > latest.c).sort((a, b) => a.price - b.price)[0]
79
+ ?? { price: resistanceFallback.h, time: resistanceFallback.t }
80
+ return {
81
+ ready: true, candlesUsed: rows.length, excludedOpen, observedAt: closeTime(latest, interval), candleOpenedAt: latest.t, close: latest.c,
82
+ sma20: mean20, ema20: emaSeries(close, 20).at(-1), ema50: emaSeries(close, 50).at(-1),
83
+ rsi14: rsi, macd: { line: macdLine.at(-1), signal: signal.at(-1), histogram: macdLine.at(-1) - signal.at(-1) },
84
+ bollinger20: { lower: mean20 - 2 * deviation, middle: mean20, upper: mean20 + 2 * deviation },
85
+ atr14, volumeRatio20: latest.v / (sma(volume.slice(0, -1), 20) || 1),
86
+ support, resistance,
87
+ }
88
+ }
@@ -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
+ }