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.
package/lib/remote.js ADDED
@@ -0,0 +1,1393 @@
1
+ /**
2
+ * dsh-hyperliquid — the `hyper` Remote namespace (Typert), the single host
3
+ * entry point for both the client UI and the agent tools. One method per
4
+ * capability; every method takes one `request` JSON value and returns plain
5
+ * JSON, mirroring dsh-bots' wire contract.
6
+ * @module lib/remote.js
7
+ */
8
+
9
+ import { homedir } from 'node:os'
10
+ import { resolve } from 'node:path'
11
+ import { HyperliquidClient, keys, sigFigs } from './hl.js'
12
+ import { canonicalMarketCoin } from './market-symbol.js'
13
+ import { HyperStore } from './store.js'
14
+ import { addressFromPrivateKey, buildL1TypedData, splitSignature } from './signing.js'
15
+ import { TradingGuard } from './trading-guard.js'
16
+
17
+ const TOOL_CALL = Symbol('verified DSH tool call')
18
+
19
+ const { Remote, TypertRemoteService } = await resolveTypertRuntime()
20
+
21
+ /** Anchor to the running dsh CLI's typert copy (dsh-bots' proven technique). */
22
+ async function resolveTypertRuntime() {
23
+ const { createRequire } = await import('node:module')
24
+ const { realpathSync } = await import('node:fs')
25
+ const candidates = []
26
+ const SPEC = '@deepseek-ai/dsh-typert-protocol'
27
+ // Test hook: HYPERLIQUID_TYPERT_MODULE pins the typert module for out-of-host
28
+ // smoke tests; inside the real dsh host the argv/execPath anchors win.
29
+ if (process.env.HYPERLIQUID_TYPERT_MODULE) {
30
+ const pinned = process.env.HYPERLIQUID_TYPERT_MODULE
31
+ candidates.push(() => {
32
+ const req = createRequire(pinned.startsWith('file:') ? import.meta.url : pinned)
33
+ return req(req.resolve(pinned.startsWith('file:') ? SPEC : pinned))
34
+ })
35
+ }
36
+ const anchors = []
37
+ for (const a of [process.argv?.[1], process.argv?.[0]]) {
38
+ if (typeof a !== 'string' || a.length === 0) continue
39
+ anchors.push(a)
40
+ try { anchors.push(realpathSync(a)) } catch { /* keep original */ }
41
+ }
42
+ // Anchor via CJS require instead of dynamic import: the dsh loader's module
43
+ // hooks stall import(fileUrl) for loader-external absolute paths, while
44
+ // require(esm) (Node ≥ 22.12) resolves the exact same module natively.
45
+ for (const a of anchors) {
46
+ candidates.push(() => {
47
+ const req = createRequire(a)
48
+ return req(req.resolve(SPEC))
49
+ })
50
+ }
51
+ // Last resort: the plugin's own node_modules walk-up.
52
+ candidates.push(() => {
53
+ const req = createRequire(import.meta.url)
54
+ return req(req.resolve(SPEC))
55
+ })
56
+ for (let i = 0; i < candidates.length; i += 1) {
57
+ try {
58
+ const m = await candidates[i]()
59
+ // require(esm) returns the module namespace; import() may wrap it as
60
+ // { default: {...} } (CJS interop) — accept both spellings.
61
+ const mod = m && m.TypertRemoteService ? m : (m && m.default && m.default.TypertRemoteService ? m.default : m)
62
+ if (mod && mod.TypertRemoteService && mod.Remote) return { Remote: mod.Remote, TypertRemoteService: mod.TypertRemoteService }
63
+ } catch { /* try next anchor */ }
64
+ }
65
+ throw new Error('[dsh-hyperliquid] 无法解析 @deepseek-ai/dsh-typert-protocol(宿主锚定与回退均失败)')
66
+ }
67
+
68
+ /** Apply one `@Remote(method)` marker via a decorator-context shim. */
69
+ function markRemoteMethod(prototype, method) {
70
+ const decorator = Remote(method)
71
+ decorator(undefined, {
72
+ name: method,
73
+ private: false,
74
+ static: false,
75
+ addInitializer(fn) { fn.call(Object.create(prototype)) },
76
+ })
77
+ }
78
+
79
+ const MARKETS_TTL_MS = 10_000
80
+ const ACCOUNT_MODE_TTL_MS = 30_000
81
+ const TRADE_CAPACITY_TTL_MS = 15_000
82
+ const SUB_SWEEP_MS = 30_000
83
+ const SUB_IDLE_MS = 120_000
84
+ const BOOK_GROUPINGS = {
85
+ full: null,
86
+ '5': { nSigFigs: 5 },
87
+ '5x2': { nSigFigs: 5, mantissa: 2 },
88
+ '5x5': { nSigFigs: 5, mantissa: 5 },
89
+ '4': { nSigFigs: 4 },
90
+ '3': { nSigFigs: 3 },
91
+ '2': { nSigFigs: 2 },
92
+ }
93
+
94
+ export class HyperRemote extends TypertRemoteService {
95
+ constructor(ctx, config = {}) {
96
+ super(ctx, 'hyper')
97
+ this.hostCtx = ctx // kept for optional-service lookups (ctx.get — never throws)
98
+ this.cfg = {
99
+ dataDir: expandHome(config?.dataDir ?? '~/.dsh-hyperliquid'),
100
+ network: config?.network === 'testnet' ? 'testnet' : 'mainnet',
101
+ tradingMode: config?.tradingMode === 'paper' ? 'paper' : 'live',
102
+ privateKey: config?.privateKey || process.env.HYPERLIQUID_PRIVATE_KEY || '',
103
+ // signer 'local': sign with config.privateKey inside this plugin.
104
+ // signer 'web3': delegate EIP-712 signing to the dsh-web3 vault
105
+ // (ctx.wallet) — the private key never leaves the vault
106
+ // and every signature passes the wallet's signing policy.
107
+ signer: config?.signer === 'web3' ? 'web3' : 'local',
108
+ web3Account: config?.web3Account ?? 'main',
109
+ tradingAddress: config?.tradingAddress ?? '',
110
+ autonomousLive: config?.autonomousLive === true,
111
+ allowedCoins: String(config?.allowedCoins ?? 'BTC,ETH').split(',').map((v) => v.trim()).filter(Boolean),
112
+ maxOrderUsd: Number(config?.maxOrderUsd ?? 100),
113
+ maxDailyUsd: Number(config?.maxDailyUsd ?? 500),
114
+ maxPositionUsd: Number(config?.maxPositionUsd ?? 500),
115
+ maxLeverage: Number(config?.maxLeverage ?? 2),
116
+ }
117
+ for (const field of ['maxOrderUsd', 'maxDailyUsd', 'maxPositionUsd', 'maxLeverage']) {
118
+ if (!Number.isFinite(this.cfg[field]) || this.cfg[field] <= 0) throw new Error(`${field} 必须为正数`)
119
+ }
120
+ if (this.cfg.autonomousLive && this.cfg.signer !== 'web3') throw new Error('autonomousLive 仅支持 signer=web3')
121
+ if (this.cfg.tradingAddress && !/^0x[0-9a-fA-F]{40}$/.test(this.cfg.tradingAddress)) throw new Error('tradingAddress 必须为 20 字节 EVM 地址')
122
+ this.client = new HyperliquidClient({ network: this.cfg.network, privateKey: this.cfg.privateKey })
123
+ this.store = new HyperStore(this.cfg.dataDir, this.cfg.network)
124
+ this.guard = new TradingGuard(this.cfg.dataDir, { ...this.cfg, dataDir: this.cfg.dataDir })
125
+ this.paperGuard = new TradingGuard(this.cfg.dataDir, { ...this.cfg, dataDir: this.cfg.dataDir }, { budgetName: this.cfg.network === 'mainnet' ? 'paper-budget-mainnet.json' : 'paper-budget.json' })
126
+ this.marketsCache = null
127
+ this.marketsAt = 0
128
+ this.catalogCache = null
129
+ this.catalogAt = 0
130
+ this.catalogDexIndex = new Map()
131
+ this.detailCache = new Map()
132
+ this.addressCache = null
133
+ this.accountModeCache = null
134
+ this.accountModePending = null
135
+ this.tradeCapacityCache = new Map()
136
+ this.tradeCapacityPending = new Map()
137
+ this.walletAddressCache = null
138
+ this.desk = { coin: 'BTC', interval: '15m', sessionId: null } // active UI session and market focus
139
+ this.lastTouch = new Map() // snapshot key -> last request ms
140
+ this.activeTradeOps = 0
141
+ // Hold the network stable through every order/cancel/leverage operation,
142
+ // including wallet approval and the exchange response.
143
+ for (const method of ['placeOrder', 'cancelOrder', 'setLeverage']) {
144
+ const operation = this[method].bind(this)
145
+ this[method] = async (...args) => {
146
+ this.activeTradeOps += 1
147
+ try { return await operation(...args) }
148
+ finally { this.activeTradeOps -= 1 }
149
+ }
150
+ }
151
+ this.sweepTimer = setInterval(() => this.sweepSubs(), SUB_SWEEP_MS)
152
+ }
153
+
154
+ sweepSubs() {
155
+ const now = Date.now()
156
+ for (const [key, at] of this.lastTouch) {
157
+ if (now - at > SUB_IDLE_MS) {
158
+ this.client.unsubscribe(key)
159
+ this.lastTouch.delete(key)
160
+ }
161
+ }
162
+ }
163
+
164
+ touch(key) {
165
+ this.lastTouch.set(key, Date.now())
166
+ // subscribe() ref-counts. Polling the desk must not increment that count.
167
+ if (!this.client.subs.has(key)) this.client.subscribe(key, argsOf(key))
168
+ return key
169
+ }
170
+
171
+ // ── dsh-web3 signer plumbing ──────────────────────────────────────────
172
+ /** The dsh-web3 wallet service, if the plugin is installed. Optional read — never throws. */
173
+ walletService() {
174
+ const w = this.hostCtx?.get?.('wallet')
175
+ return w && typeof w.signTypedData === 'function' ? w : null
176
+ }
177
+
178
+ /** Harness approval gateway (same resolution dsh-web3's own tools use). */
179
+ approvalGateway() {
180
+ return this.hostCtx?.get?.('approval') ?? { async request() { return 'unavailable' } }
181
+ }
182
+
183
+ /** Resolve the configured web3 account's address from the vault. */
184
+ async web3Address() {
185
+ const wallet = this.walletService()
186
+ if (!wallet) throw new Error('signer=web3 但 dsh-web3 未安装或未启用 —— 请执行 dsh plugin --profile web add dsh-web3')
187
+ const accounts = typeof wallet.listAccounts === 'function' ? wallet.listAccounts() : []
188
+ if (accounts.length === 0) throw new Error('dsh-web3 保险库里还没有账户 —— 请在 DSH 设置的「Web3 钱包」页创建或导入')
189
+ const want = this.cfg.web3Account
190
+ const meta = accounts.find((a) => a?.id === want || a?.label === want)
191
+ if (!meta) throw new Error(`dsh-web3 中找不到指定签名账户:${want}`)
192
+ if (!meta?.address) throw new Error('dsh-web3 账户缺少地址')
193
+ this.walletAddressCache = meta.address
194
+ return meta.address
195
+ }
196
+
197
+ /**
198
+ * Trading address for reads (status/account/orders/fills).
199
+ * local → derived from config.privateKey; web3 → the vault account.
200
+ */
201
+ async resolveAddress() {
202
+ if (this.cfg.tradingAddress) return this.cfg.tradingAddress
203
+ if (this.cfg.signer === 'web3') {
204
+ // An API wallet signs for its owner; account reads must use the owner.
205
+ return null
206
+ }
207
+ if (!this.cfg.privateKey) return null
208
+ try { return (this.addressCache ??= await addressFromPrivateKey(this.cfg.privateKey)) } catch { return null }
209
+ }
210
+
211
+ /** The account abstraction mode changes where Hyperliquid reports collateral. */
212
+ async accountMode(address, fresh = false) {
213
+ const key = address.toLowerCase()
214
+ if (!fresh && this.accountModeCache?.address === key && Date.now() - this.accountModeCache.at < ACCOUNT_MODE_TTL_MS) return this.accountModeCache.mode
215
+ if (this.accountModePending?.address === key) return this.accountModePending.promise
216
+ const promise = this.client.userAbstraction(address).then((mode) => {
217
+ if (!['default', 'disabled', 'dexAbstraction', 'unifiedAccount', 'portfolioMargin'].includes(mode)) throw new Error(`交易所返回未知账户模式:${String(mode)}`)
218
+ this.accountModeCache = { address: key, mode, at: Date.now() }
219
+ return mode
220
+ }).finally(() => { if (this.accountModePending?.promise === promise) this.accountModePending = null })
221
+ this.accountModePending = { address: key, promise }
222
+ return promise
223
+ }
224
+
225
+ /** UI-only capacity cache. Order preflight queries the exchange afresh. */
226
+ async activeAssetFor(address, coin, accountMode) {
227
+ const key = `${address.toLowerCase()}:${accountMode}:${coin}`
228
+ const cached = this.tradeCapacityCache?.get(key)
229
+ if (cached && Date.now() - cached.at < TRADE_CAPACITY_TTL_MS) return cached.value
230
+ const pending = this.tradeCapacityPending?.get(key)
231
+ if (pending) return pending
232
+ const promise = this.client.activeAssetData(address, coin).then((value) => {
233
+ if (!validTradeCapacity(value, coin)) throw new Error('交易所可交易额度响应无效')
234
+ this.tradeCapacityCache ??= new Map()
235
+ this.tradeCapacityCache.set(key, { value, at: Date.now() })
236
+ if (this.tradeCapacityCache.size > 256) {
237
+ for (const [oldKey, row] of this.tradeCapacityCache) {
238
+ if (Date.now() - row.at >= TRADE_CAPACITY_TTL_MS) this.tradeCapacityCache.delete(oldKey)
239
+ }
240
+ while (this.tradeCapacityCache.size > 256) this.tradeCapacityCache.delete(this.tradeCapacityCache.keys().next().value)
241
+ }
242
+ return value
243
+ }).finally(() => this.tradeCapacityPending?.delete(key))
244
+ this.tradeCapacityPending ??= new Map()
245
+ this.tradeCapacityPending.set(key, promise)
246
+ return promise
247
+ }
248
+
249
+ /**
250
+ * THE live exchange submit. signer=local keeps the validated in-plugin
251
+ * EIP-712 path; signer=web3 builds the identical typed-data payload and
252
+ * lets dsh-web3 sign it under its signing policy (approval gates, PIN,
253
+ * audit log) — then only transports the opaque signature.
254
+ */
255
+ async submitAction(action, exec) {
256
+ if (this.cfg.signer !== 'web3') return this.client.exchange(action)
257
+ const wallet = this.walletService()
258
+ if (!wallet) throw new Error('signer=web3 但 dsh-web3 未安装 —— 请执行 dsh plugin --profile web add dsh-web3')
259
+ const nonce = this.client.nextNonce()
260
+ const typed = await buildL1TypedData(action, nonce, { isMainnet: this.cfg.network === 'mainnet' })
261
+ const purpose = `Hyperliquid ${this.cfg.network} 交易签名:${action?.type}` +
262
+ (action?.type === 'order' ? ` ${action.orders?.[0]?.b ? '买入' : '卖出'} ${action.orders?.[0]?.s} @ ${action.orders?.[0]?.p}` : '')
263
+ const result = await wallet.signTypedData(
264
+ { account: this.cfg.web3Account, ...typed, purpose },
265
+ this.approvalGateway(),
266
+ { toolName: 'dsh-hyperliquid', agent: exec?.agent, callId: exec?.callId, signal: exec?.signal },
267
+ )
268
+ if (result?.status !== 'signed' || !result.signature) {
269
+ const why = {
270
+ 'awaiting-approval-rejected': '用户在钱包审批中拒绝了该签名',
271
+ 'denied-by-policy': '钱包签名策略拒绝了该签名(在 DSH 设置 → Web3 钱包调整签名策略;与交易台的交易限额无关)',
272
+ 'unavailable-approver': '没有可用的审批通道(钱包安全模式要求逐笔确认)',
273
+ }[result?.status] ?? `签名未完成(status=${result?.status})`
274
+ throw new Error(`dsh-web3 签名失败:${why}`)
275
+ }
276
+ const signerAddress = await this.web3Address()
277
+ if (result.address?.toLowerCase() !== signerAddress.toLowerCase()) throw new Error('dsh-web3 返回的签名地址与配置账户不一致')
278
+ const signature = splitSignature(result.signature)
279
+ return this.client.exchangeSigned(action, signature, nonce)
280
+ }
281
+
282
+ /** Mid price for a perp coin: WS allMids first, REST fallback. */
283
+ async midOf(coin) {
284
+ this.touch(keys.allMids())
285
+ const snap = this.client.snapshot(keys.allMids())
286
+ if (snap?.data && Date.now() - snap.at < 5_000) {
287
+ const v = snap.data.mids?.[coin] ?? snap.data[coin]
288
+ const n = Number(v)
289
+ if (Number.isFinite(n) && n > 0) return n
290
+ }
291
+ try {
292
+ const mids = await this.client.allMids()
293
+ const n = Number(mids?.mids?.[coin] ?? mids?.[coin])
294
+ if (Number.isFinite(n) && n > 0) return n
295
+ } catch { /* fall through */ }
296
+ const markets = await this.markets()
297
+ const row = markets.universe.find((u) => u.name === coin)
298
+ if (row?.markPx) return Number(row.markPx)
299
+ throw new Error(`无法获得 ${coin} 中间价`)
300
+ }
301
+
302
+ async markets(request) {
303
+ const now = Date.now()
304
+ if (this.marketsCache && now - this.marketsAt < MARKETS_TTL_MS) return this.marketsCache
305
+ const client = this.client
306
+ const ctxs = await client.metaAndAssetCtxs()
307
+ if (client !== this.client) throw new Error('网络已切换,忽略旧网络市场数据')
308
+ const universe = (ctxs?.[0]?.universe ?? []).map((u, i) => ({
309
+ name: u.name,
310
+ szDecimals: u.szDecimals,
311
+ maxLeverage: u.maxLeverage,
312
+ onlyIsolated: u.onlyIsolated ?? false,
313
+ ctx: ctxs?.[1]?.[i] ?? null,
314
+ }))
315
+ this.marketsCache = { universe, network: this.cfg.network }
316
+ this.marketsAt = now
317
+ return this.marketsCache
318
+ }
319
+
320
+ /** Search catalog for the market picker. Extra venues are view-only until
321
+ * their asset IDs, account state and order guards have dedicated support. */
322
+ async marketCatalog(request) {
323
+ const now = Date.now()
324
+ const client = this.client
325
+ const ctxKey = this.touch(keys.allDexsAssetCtxs())
326
+ if (!this.catalogCache || now - this.catalogAt >= 60_000) {
327
+ const base = await this.markets()
328
+ const [spotResult, perpResult, outcomeResult, categoryResult] = await Promise.allSettled([
329
+ client.info('spotMetaAndAssetCtxs'),
330
+ client.info('allPerpMetas'),
331
+ this.cfg.network === 'testnet' ? client.info('outcomeMeta') : Promise.resolve(null),
332
+ client.info('perpCategories'),
333
+ ])
334
+ if (client !== this.client) throw new Error('网络已切换,忽略旧网络市场目录')
335
+ const categories = new Map(categoryResult.status === 'fulfilled' && Array.isArray(categoryResult.value)
336
+ ? categoryResult.value.filter((pair) => Array.isArray(pair) && typeof pair[0] === 'string' && typeof pair[1] === 'string') : [])
337
+ const rows = base.universe.map((row) => ({
338
+ name: row.name, label: `${row.name}-USDC`, kind: 'perp', venue: 'Hyperliquid',
339
+ tradable: true, ctx: row.ctx, maxLeverage: row.maxLeverage,
340
+ }))
341
+ const dexIndex = new Map()
342
+ if (perpResult.status === 'fulfilled' && Array.isArray(perpResult.value)) {
343
+ for (const item of perpResult.value.slice(1)) {
344
+ const meta = Array.isArray(item) ? item[0] : item
345
+ const contexts = Array.isArray(item) ? item[1] : null
346
+ for (const [index, asset] of (meta?.universe ?? []).entries()) {
347
+ const dex = asset.name.split(':')[0] || 'HIP-3'
348
+ dexIndex.set(asset.name, { dex, index })
349
+ rows.push({ name: asset.name, label: asset.name, kind: 'hip3',
350
+ venue: dex, tradable: false, ctx: contexts?.[index] ?? null,
351
+ category: categories.get(asset.name) ?? null, maxLeverage: asset.maxLeverage })
352
+ }
353
+ }
354
+ }
355
+ if (spotResult.status === 'fulfilled') {
356
+ const [meta, ctxs] = spotResult.value ?? []
357
+ const tokens = new Map((meta?.tokens ?? []).map((token) => [token.index, token.name]))
358
+ for (const [index, asset] of (meta?.universe ?? []).entries()) {
359
+ // spotMetaAndAssetCtxs returns contexts in universe order; contexts
360
+ // have no `coin` field in the documented response.
361
+ const ctx = ctxs?.[index]
362
+ const base = tokens.get(asset.tokens?.[0])
363
+ const quote = tokens.get(asset.tokens?.[1])
364
+ const label = base && quote ? `${base}/${quote}` : asset.name
365
+ rows.push({ name: asset.name, label, kind: 'spot', venue: 'Spot',
366
+ tradable: false, ctx: ctx ?? null, maxLeverage: null })
367
+ }
368
+ }
369
+ if (outcomeResult.status === 'fulfilled') {
370
+ for (const outcome of outcomeResult.value?.outcomes ?? []) {
371
+ if (!Number.isSafeInteger(outcome?.outcome) || outcome.outcome < 0) continue
372
+ for (const [side, spec] of (outcome.sideSpecs ?? []).entries()) {
373
+ if (side > 1) break
374
+ const name = `#${10 * outcome.outcome + side}`
375
+ rows.push({ name, label: `${outcome.name || 'Outcome'} · ${spec?.name || (side === 0 ? 'Yes' : 'No')} #${outcome.outcome}`, kind: 'outcome',
376
+ venue: `Outcome #${outcome.outcome}`, tradable: false, ctx: null, maxLeverage: null })
377
+ }
378
+ }
379
+ }
380
+ this.catalogDexIndex = dexIndex
381
+ this.catalogCache = { markets: rows, network: this.cfg.network, observedAt: now }
382
+ this.catalogAt = now
383
+ }
384
+ const live = this.client.snapshot(ctxKey)
385
+ if (!live || now - live.at > 15_000 || !Array.isArray(live.data?.ctxs)) return this.catalogCache
386
+ const byDex = new Map(live.data.ctxs.filter((entry) => Array.isArray(entry) && typeof entry[0] === 'string' && Array.isArray(entry[1])))
387
+ const markets = this.catalogCache.markets.map((row) => {
388
+ const location = this.catalogDexIndex.get(row.name)
389
+ const ctx = location ? byDex.get(location.dex)?.[location.index] : null
390
+ return ctx ? { ...row, ctx } : row
391
+ })
392
+ return { ...this.catalogCache, markets, ctxObservedAt: live.at }
393
+ }
394
+
395
+ /** Refresh one selected market; the picker catalog is not the live price source. */
396
+ async marketDetail(request) {
397
+ const coin = requireCoin(request)
398
+ // Core perps have a small, separately cached metadata endpoint. Do not
399
+ // build the broad spot/HIP-3 catalog merely to refresh a BTC strip.
400
+ if (!coin.includes(':') && !coin.startsWith('#') && !coin.startsWith('@') && !coin.includes('/')) {
401
+ const { universe } = await this.markets()
402
+ const market = universe.find((entry) => entry.name === coin)
403
+ if (market) return { market: { name: coin, label: `${coin}-USDC`, kind: 'perp', venue: 'Hyperliquid', tradable: true,
404
+ ctx: market.ctx, maxLeverage: market.maxLeverage }, observedAt: this.marketsAt }
405
+ }
406
+ const catalog = await this.marketCatalog()
407
+ const row = catalog.markets.find((market) => market.name === coin)
408
+ if (!row) throw new Error(`市场目录中没有 ${coin}`)
409
+ if (row.kind === 'spot') {
410
+ const cached = this.detailCache.get('__spot__')
411
+ if (cached && Date.now() - cached.at < 15_000) return { market: { ...row, ctx: cached.rows.get(coin) ?? row.ctx }, observedAt: cached.at }
412
+ const [meta, ctxs] = await this.client.info('spotMetaAndAssetCtxs')
413
+ const rows = new Map((meta?.universe ?? []).map((asset, index) => [asset.name, ctxs?.[index] ?? null]))
414
+ const at = Date.now()
415
+ this.detailCache.set('__spot__', { at, rows })
416
+ return { market: { ...row, ctx: rows.get(coin) ?? null }, observedAt: at }
417
+ }
418
+ if (row.kind === 'outcome') {
419
+ const key = this.touch(keys.l2Book(coin))
420
+ const live = this.client.snapshot(key)
421
+ const fresh = live && Date.now() - live.at < 5_000 && Array.isArray(live.data?.levels)
422
+ const book = fresh ? live.data : await this.client.l2Book(coin).catch(() => null)
423
+ const bid = Number(book?.levels?.[0]?.[0]?.px)
424
+ const ask = Number(book?.levels?.[1]?.[0]?.px)
425
+ const markPx = bid > 0 && ask > 0 ? String(Number(((bid + ask) / 2).toPrecision(12))) : bid > 0 ? String(bid) : ask > 0 ? String(ask) : null
426
+ return { market: { ...row, ctx: markPx ? { markPx, midPx: markPx } : null }, observedAt: book ? (fresh ? live.at : Date.now()) : 0 }
427
+ }
428
+ if (row.ctx && catalog.ctxObservedAt && Date.now() - catalog.ctxObservedAt < 10_000) {
429
+ return { market: row, observedAt: catalog.ctxObservedAt }
430
+ }
431
+ const dex = coin.split(':')[0]
432
+ const cached = this.detailCache.get(dex)
433
+ if (cached && Date.now() - cached.at < 15_000) return { market: { ...row, ctx: cached.rows.get(coin) ?? row.ctx }, observedAt: cached.at }
434
+ const [meta, ctxs] = await this.client.info('metaAndAssetCtxs', { dex })
435
+ const rows = new Map((meta?.universe ?? []).map((asset, index) => [asset.name, ctxs?.[index] ?? null]))
436
+ const at = Date.now()
437
+ this.detailCache.set(dex, { at, rows })
438
+ return { market: { ...row, ctx: rows.get(coin) ?? row.ctx }, observedAt: at }
439
+ }
440
+
441
+ /** Browser reads only the marks for its active registered DSH session. */
442
+ async chartMarks(request) {
443
+ const sessionId = String(request?.sessionId ?? '')
444
+ if (!this.store.getSession(sessionId)) return { marks: [] }
445
+ const coin = requireCoin(request)
446
+ const interval = requireInterval(request?.interval ?? this.desk.interval)
447
+ return { marks: this.store.listChartMarks(sessionId, coin, interval) }
448
+ }
449
+
450
+ addChartMark(request, sessionId, source = 'agent') {
451
+ this.requireTradingSession(sessionId)
452
+ const coin = requireCoin(request)
453
+ const interval = requireInterval(request?.interval ?? this.desk.interval)
454
+ const kind = request?.kind === 'trend' ? 'trend' : 'level'
455
+ const role = ['support', 'resistance', 'neutral'].includes(request?.role) ? request.role : 'neutral'
456
+ const label = String(request?.label ?? '').trim()
457
+ if (!label || label.length > 60) throw new Error('图表标注需要 1-60 字标签')
458
+ const mark = { sessionId, coin, interval, kind, role, label, source }
459
+ if (kind === 'level') {
460
+ const price = Number(request?.price)
461
+ if (!Number.isFinite(price) || price <= 0) throw new Error('水平线 price 必须为正数')
462
+ mark.price = price
463
+ } else {
464
+ const fromTime = Number(request?.fromTime)
465
+ const toTime = Number(request?.toTime)
466
+ const fromPrice = Number(request?.fromPrice)
467
+ const toPrice = Number(request?.toPrice)
468
+ const oldest = Date.now() - 90 * 86_400_000
469
+ const latest = Date.now() + 86_400_000
470
+ if (!(fromTime >= oldest && toTime > fromTime && toTime <= latest && fromPrice > 0 && toPrice > 0 && Number.isFinite(fromPrice) && Number.isFinite(toPrice))) {
471
+ throw new Error('趋势线需要最近 90 天内递增的毫秒时间戳及正数价格')
472
+ }
473
+ Object.assign(mark, { fromTime, toTime, fromPrice, toPrice })
474
+ }
475
+ return this.store.addChartMark(mark)
476
+ }
477
+
478
+ removeChartMark(id, sessionId) {
479
+ this.requireTradingSession(sessionId)
480
+ return this.store.removeChartMark(sessionId, String(id ?? ''))
481
+ }
482
+
483
+ /** Session-scoped workbench state shared by the native UI and agent tools. */
484
+ async sessionWorkspace(request) {
485
+ return this.store.getSessionWorkspace(String(request?.sessionId ?? ''))
486
+ }
487
+
488
+ async updateSessionWorkspace(request) {
489
+ const sessionId = String(request?.sessionId ?? '')
490
+ this.requireTradingSession(sessionId)
491
+ const input = request?.patch ?? {}
492
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('工作台配置无效')
493
+ const patch = {}
494
+ if ('interval' in input) patch.interval = requireInterval(input.interval)
495
+ if ('bookGrouping' in input) {
496
+ if (!Object.hasOwn(BOOK_GROUPINGS, input.bookGrouping)) throw new Error('盘口精度无效')
497
+ patch.bookGrouping = input.bookGrouping
498
+ }
499
+ if ('chartTab' in input) {
500
+ if (!['chart', 'funding'].includes(input.chartTab)) throw new Error('图表页签无效')
501
+ patch.chartTab = input.chartTab
502
+ }
503
+ if ('tapeTab' in input) {
504
+ if (!['book', 'trades'].includes(input.tapeTab)) throw new Error('行情页签无效')
505
+ patch.tapeTab = input.tapeTab
506
+ }
507
+ if ('bottomTab' in input) {
508
+ if (!['positions', 'orders', 'account'].includes(input.bottomTab)) throw new Error('账户页签无效')
509
+ patch.bottomTab = input.bottomTab
510
+ }
511
+ if ('indicators' in input) {
512
+ const allowed = ['volume', 'sma20', 'sma60', 'ema20', 'ema50', 'bollinger20', 'rsi14', 'macd']
513
+ if (!Array.isArray(input.indicators) || input.indicators.length > allowed.length || input.indicators.some((id) => !allowed.includes(id))) throw new Error('图表指标无效')
514
+ patch.indicators = [...new Set(input.indicators)]
515
+ }
516
+ if ('priceScale' in input) {
517
+ if (!['normal', 'log', 'percentage'].includes(input.priceScale)) throw new Error('价格刻度无效')
518
+ patch.priceScale = input.priceScale
519
+ }
520
+ if ('chartLayers' in input) {
521
+ const layers = input.chartLayers
522
+ if (!layers || typeof layers !== 'object' || Array.isArray(layers) || Object.keys(layers).some((key) => !['orders', 'fills', 'drawings'].includes(key)) || Object.values(layers).some((value) => typeof value !== 'boolean')) throw new Error('图表图层无效')
523
+ patch.chartLayers = layers
524
+ }
525
+ if ('layout' in input) {
526
+ const l = input.layout
527
+ if (!l || typeof l !== 'object' || Array.isArray(l)) throw new Error('工作台布局无效')
528
+ const clean = {}
529
+ for (const [key, min, max] of [['sessionsWidth', 160, 400], ['chatWidth', 320, 700]]) {
530
+ if (key in l) {
531
+ const value = Number(l[key])
532
+ if (!Number.isFinite(value)) throw new Error('工作台宽度无效')
533
+ clean[key] = Math.max(min, Math.min(max, Math.round(value)))
534
+ }
535
+ }
536
+ for (const key of ['sessionsCollapsed', 'deskCollapsed', 'chatCollapsed']) {
537
+ if (key in l) {
538
+ if (typeof l[key] !== 'boolean') throw new Error('工作台折叠状态无效')
539
+ clean[key] = l[key]
540
+ }
541
+ }
542
+ patch.layout = clean
543
+ }
544
+ if ('chartRanges' in input) {
545
+ const ranges = input.chartRanges
546
+ if (!ranges || typeof ranges !== 'object' || Array.isArray(ranges) || Object.keys(ranges).length > 1) throw new Error('图表视区无效')
547
+ const clean = {}
548
+ for (const [key, range] of Object.entries(ranges)) {
549
+ if (key.length > 100 || key === '__proto__' || !range || !Number.isFinite(range.from) || !Number.isFinite(range.to) || range.from >= range.to) throw new Error('图表视区无效')
550
+ clean[key] = { from: range.from, to: range.to }
551
+ }
552
+ patch.chartRanges = clean
553
+ }
554
+ const result = this.store.updateSessionWorkspace(sessionId, patch)
555
+ if (this.desk?.sessionId === sessionId && patch.interval) this.desk.interval = patch.interval
556
+ return result
557
+ }
558
+
559
+ async drawChartMark(request) {
560
+ const sessionId = String(request?.sessionId ?? '')
561
+ return this.addChartMark(request, sessionId, 'user')
562
+ }
563
+
564
+ async eraseChartMark(request) {
565
+ const sessionId = String(request?.sessionId ?? '')
566
+ return this.removeChartMark(request?.id, sessionId)
567
+ }
568
+
569
+ // ── remote methods ───────────────────────────────────────────────────
570
+
571
+ /** Change the desk's data and trade network for this host process. */
572
+ async switchNetwork(request) {
573
+ const network = request?.network
574
+ if (network !== 'mainnet' && network !== 'testnet') throw new Error('network 必须为 mainnet 或 testnet')
575
+ if (network === this.cfg.network) return { network }
576
+ if (this.activeTradeOps > 0) throw new Error('交易操作仍在进行,无法切换网络')
577
+ if (network === 'mainnet' && request?.acknowledgeMainnet !== true) throw new Error('切换主网需要确认')
578
+ const nextPaperGuard = this.cfg.dataDir ? new TradingGuard(this.cfg.dataDir, { ...this.cfg, dataDir: this.cfg.dataDir }, { budgetName: network === 'mainnet' ? 'paper-budget-mainnet.json' : 'paper-budget.json' }) : null
579
+ this.store.selectPaperNetwork?.(network)
580
+ const previous = this.cfg.network
581
+ this.client.close()
582
+ this.cfg.network = network
583
+ this.client = new HyperliquidClient({ network, privateKey: this.cfg.privateKey })
584
+ this.lastTouch.clear()
585
+ this.marketsCache = null
586
+ this.marketsAt = 0
587
+ this.catalogCache = null
588
+ this.catalogAt = 0
589
+ this.catalogDexIndex.clear()
590
+ this.detailCache.clear()
591
+ this.accountModeCache = null
592
+ this.accountModePending = null
593
+ this.tradeCapacityCache.clear()
594
+ this.tradeCapacityPending.clear()
595
+ this.guard.plans.clear()
596
+ if (nextPaperGuard) this.paperGuard = nextPaperGuard
597
+ this.store.recordEvent('network.changed', { from: previous, to: network })
598
+ return { network }
599
+ }
600
+
601
+ /** Change between real-account (live) and local paper trading for this host process. */
602
+ async switchTradingMode(request) {
603
+ const mode = request?.mode
604
+ if (mode !== 'live' && mode !== 'paper') throw new Error('mode 必须为 live 或 paper')
605
+ if (mode === this.cfg.tradingMode) return { mode }
606
+ if (this.activeTradeOps > 0) throw new Error('交易操作仍在进行,无法切换交易模式')
607
+ if (mode === 'live' && request?.acknowledgeLive !== true) throw new Error('切换实盘需要确认')
608
+ const previous = this.cfg.tradingMode
609
+ this.cfg.tradingMode = mode
610
+ this.accountModeCache = null
611
+ this.accountModePending = null
612
+ this.tradeCapacityCache.clear()
613
+ this.tradeCapacityPending.clear()
614
+ this.guard.plans.clear()
615
+ this.store.recordEvent('mode.changed', { from: previous, to: mode })
616
+ return { mode }
617
+ }
618
+
619
+ /** Today's (UTC) notional already counted against maxDailyUsd for the active mode. */
620
+ usedTodayUsd() {
621
+ const guard = this.cfg.tradingMode === 'paper' ? this.paperGuard : this.guard
622
+ if (!guard?.budget) return 0
623
+ return guard.budget.day === new Date().toISOString().slice(0, 10) ? guard.budget.usedUsd : 0
624
+ }
625
+
626
+ /** Plugin status: network, mode, account identity, ws health. */
627
+ async status(request) {
628
+ const address = await this.resolveAddress()
629
+ const wallet = this.cfg.signer === 'web3' ? this.walletService() : null
630
+ return {
631
+ network: this.cfg.network,
632
+ tradingMode: this.cfg.tradingMode,
633
+ signer: this.cfg.signer,
634
+ autonomousLive: this.cfg.autonomousLive,
635
+ riskLimits: { allowedCoins: this.cfg.allowedCoins, maxOrderUsd: this.cfg.maxOrderUsd, maxDailyUsd: this.cfg.maxDailyUsd, maxPositionUsd: this.cfg.maxPositionUsd, maxLeverage: this.cfg.maxLeverage,
636
+ // Day budget of the ledger that applies to the current mode (paper and live are separate).
637
+ usedTodayUsd: this.usedTodayUsd() },
638
+ walletConfigured: this.cfg.signer === 'web3' ? Boolean(wallet && this.cfg.tradingAddress) : Boolean(this.cfg.privateKey),
639
+ address,
640
+ ws: this.client.wsStatus(),
641
+ desk: { ...this.desk },
642
+ eventSeq: this.store.eventSeq,
643
+ }
644
+ }
645
+
646
+ /** Live snapshot for one coin: mids + book + recent trades (REST fallbacks for the WS warm-up window). */
647
+ async snapshot(request) {
648
+ const client = this.client
649
+ const network = this.cfg?.network ?? client.network ?? 'testnet'
650
+ const coin = requireCoin(request)
651
+ const interval = requireInterval(request?.interval ?? this.desk.interval)
652
+ const bookGrouping = request?.bookGrouping ?? 'full'
653
+ if (!Object.hasOwn(BOOK_GROUPINGS, bookGrouping)) throw new Error(`不支持的盘口价格档位:${bookGrouping}`)
654
+ const grouping = BOOK_GROUPINGS[bookGrouping]
655
+ const bookKey = this.touch(keys.l2Book(coin))
656
+ const groupedKey = grouping ? this.touch(keys.l2Book(coin, grouping)) : null
657
+ const tradesKey = this.touch(keys.trades(coin))
658
+ const candleKey = this.touch(keys.candle(coin, interval))
659
+ this.touch(keys.allMids())
660
+ const book = client.snapshot(bookKey)
661
+ const groupedBook = groupedKey ? client.snapshot(groupedKey) : null
662
+ const trades = client.snapshot(tradesKey)
663
+ const candle = client.snapshot(candleKey)
664
+ const mids = client.snapshot(keys.allMids())
665
+ const now = Date.now()
666
+ const bookFresh = book && now - book.at < 5_000
667
+ const groupedFresh = groupedBook && now - groupedBook.at < 5_000
668
+ const midsFresh = mids && now - mids.at < 5_000
669
+ // Warm-up fallback: the upstream WS needs ~1s after first touch; paint
670
+ // the first frame from REST instead of leaving the desk blank.
671
+ const [restBook, restMids, restGrouped] = await Promise.all([
672
+ bookFresh ? null : client.l2Book(coin).catch(() => null),
673
+ midsFresh ? null : client.allMids().catch(() => null),
674
+ !grouping || groupedFresh ? null : client.l2Book(coin, grouping).catch(() => null),
675
+ ])
676
+ if (client !== this.client) throw new Error('网络已切换,忽略旧网络行情')
677
+ return {
678
+ network,
679
+ coin,
680
+ book: bookFresh ? book.data : (restBook ?? book?.data ?? null),
681
+ bookGrouping,
682
+ groupedBook: grouping ? (groupedFresh ? groupedBook.data : restGrouped) : null,
683
+ trades: trades?.data ?? null,
684
+ candle: candle?.data ?? null,
685
+ interval,
686
+ mids: midsFresh ? mids.data : (restMids ?? mids?.data ?? null),
687
+ observedAt: { book: restBook ? Date.now() : (book?.at ?? 0), groupedBook: groupedFresh ? groupedBook.at : restGrouped ? Date.now() : 0, trades: trades?.at ?? 0, candle: candle?.at ?? 0, mids: restMids ? Date.now() : (mids?.at ?? 0) },
688
+ seqs: { book: book?.seq ?? 0, groupedBook: groupedBook?.seq ?? 0, trades: trades?.seq ?? 0, candle: candle?.seq ?? 0, mids: mids?.seq ?? 0 },
689
+ ws: client.wsStatus(),
690
+ }
691
+ }
692
+
693
+ /** Candle history via REST (the WS candle channel is an M2 upgrade). */
694
+ async candles(request) {
695
+ const coin = requireCoin(request)
696
+ const interval = requireInterval(request?.interval ?? '1h')
697
+ const endMs = Number(request?.endMs) || Date.now()
698
+ const startMs = Number(request?.startMs) || endMs - defaultSpanMs(interval)
699
+ let rows
700
+ try {
701
+ rows = await this.client.candleSnapshot(coin, interval, startMs, endMs)
702
+ } catch (error) {
703
+ // The info endpoint returns HTTP 500 for an unknown coin. Distinguish an
704
+ // invalid symbol from a real market whose candle service is unavailable.
705
+ if (error?.response?.status === 500 || /^500\s/.test(String(error?.message ?? ''))) {
706
+ const catalog = await this.marketCatalog().catch(() => null)
707
+ if (catalog && !catalog.markets.some((market) => market.name === coin)) {
708
+ throw new Error(`未知市场 ${coin};请用 hyper_market action=catalog 查询官方合约名`, { cause: error })
709
+ }
710
+ }
711
+ throw error
712
+ }
713
+ const list = Array.isArray(rows) ? rows : []
714
+ // HL candle timestamps are already ms since epoch.
715
+ return { coin, interval, candles: list.map((c) => ({
716
+ t: Number(c.t ?? c.time ?? 0),
717
+ T: Number(c.T ?? c.closeTime ?? 0),
718
+ o: Number(c.o), h: Number(c.h), l: Number(c.l), c: Number(c.c), v: Number(c.v),
719
+ })) }
720
+ }
721
+
722
+ /** Recent hourly funding rates for the selected perpetual market. */
723
+ async fundingHistory(request) {
724
+ const coin = requireCoin(request)
725
+ const startTime = Date.now() - 7 * 86400_000
726
+ const rows = await this.client.info('fundingHistory', { coin, startTime })
727
+ return { coin, rows: Array.isArray(rows) ? rows.slice(-168) : [] }
728
+ }
729
+
730
+ /** Account view: mode-aware balances and all-DEX positions when WS is fresh. */
731
+ async account(request) {
732
+ if (this.cfg.tradingMode === 'paper') {
733
+ const paper = this.store.paperAccount()
734
+ const mids = await safeMids(this)
735
+ const risk = paperRiskState(paper, mids)
736
+ return { mode: 'paper', usd: paper.usd, equity: risk.equity, uPnl: risk.uPnl,
737
+ marginUsed: risk.marksConfirmed ? risk.marginUsed : null,
738
+ availableMargin: risk.marksConfirmed ? Math.max(0, risk.equity - risk.marginUsed) : null,
739
+ marksConfirmed: risk.marksConfirmed, positions: risk.positions, fills: paper.fills.slice(0, 50),
740
+ simulation: '固定滑点;未模拟盘口深度、手续费、资金费或强平' }
741
+ }
742
+ const address = await this.resolveAddress()
743
+ if (!address) throw new Error('live 模式需要钱包(config.privateKey 或 signer=web3)')
744
+ const accountMode = await this.accountMode(address)
745
+ const sharedBalance = accountMode === 'unifiedAccount' || accountMode === 'portfolioMargin'
746
+ const activeCoin = canonicalMarketCoin(request?.coin ?? this.desk?.coin)
747
+ const accountDex = activeCoin.includes(':') ? activeCoin.split(':')[0] : ''
748
+ const stateKey = this.touch(keys.allDexsClearinghouseState(address))
749
+ const wsState = this.client.snapshot(stateKey)
750
+ const liveStates = wsState && Date.now() - wsState.at < 10_000 &&
751
+ wsState.data?.user?.toLowerCase() === address.toLowerCase() && Array.isArray(wsState.data?.clearinghouseStates)
752
+ ? new Map(wsState.data.clearinghouseStates.filter((entry) => Array.isArray(entry) && typeof entry[0] === 'string' && entry[1] && Array.isArray(entry[1].assetPositions)))
753
+ : null
754
+ const [state, open, markets, spot] = await Promise.all([
755
+ liveStates?.get(accountDex) ?? (accountDex
756
+ ? this.client.info('clearinghouseState', { user: address, dex: accountDex })
757
+ : this.client.clearinghouseState(address)),
758
+ this.client.frontendOpenOrders(address),
759
+ this.markets().catch(() => null),
760
+ sharedBalance ? this.client.spotClearinghouseState(address) : Promise.resolve(null),
761
+ ])
762
+ if (!state || !Array.isArray(state.assetPositions) || (!sharedBalance && (!state.marginSummary || !Number.isFinite(Number(state.marginSummary.accountValue))))) {
763
+ throw new Error('交易所永续账户响应无效;账户状态未确认')
764
+ }
765
+ if (!Array.isArray(open)) throw new Error('交易所挂单响应无效;账户状态未确认')
766
+ if (sharedBalance && !Array.isArray(spot?.balances)) throw new Error('交易所统一账户余额响应无效;账户状态未确认')
767
+ const usdcRow = sharedBalance ? spot.balances.find((row) => row.coin === 'USDC' && row.token === 0) : null
768
+ const usdcBalance = sharedBalance ? Number(usdcRow?.total ?? 0) : null
769
+ const unheldUsdc = sharedBalance ? usdcBalance - Number(usdcRow?.hold ?? 0) : null
770
+ if (sharedBalance && (!Number.isFinite(usdcBalance) || !Number.isFinite(unheldUsdc))) throw new Error('统一账户 USDC 余额无效')
771
+ const isCorePerp = Boolean(markets?.universe?.some((market) => market.name === activeCoin))
772
+ const tradeCapacity = sharedBalance && isCorePerp
773
+ ? await this.activeAssetFor(address, activeCoin, accountMode) : null
774
+ const marketByCoin = new Map([...(markets?.universe ?? []), ...(this.catalogCache?.markets ?? [])].map((market) => [market.name, market]))
775
+ const positionStates = liveStates ? new Map(liveStates) : new Map()
776
+ positionStates.set(accountDex, state)
777
+ const assetPositions = [...positionStates].flatMap(([dex, dexState]) => (dexState.assetPositions ?? []).map((row) => ({
778
+ dex,
779
+ coin: row.position?.coin,
780
+ size: Number(row.position?.szi ?? 0),
781
+ entryPx: Number(row.position?.entryPx ?? 0),
782
+ markPx: Number(marketByCoin.get(row.position?.coin)?.ctx?.markPx) ||
783
+ (Number(row.position?.szi) ? Number(row.position?.positionValue) / Math.abs(Number(row.position.szi)) : null),
784
+ positionValue: Number(row.position?.positionValue ?? 0),
785
+ uPnl: Number(row.position?.unrealizedPnl ?? 0),
786
+ returnOnEquity: row.position?.returnOnEquity == null ? null : Number(row.position.returnOnEquity),
787
+ liquidationPx: row.position?.liquidationPx == null ? null : Number(row.position.liquidationPx),
788
+ marginUsed: row.position?.marginUsed == null ? null : Number(row.position.marginUsed),
789
+ cumFunding: row.position?.cumFunding ?? null,
790
+ maxLeverage: row.position?.maxLeverage ?? null,
791
+ leverage: row.position?.leverage ?? null,
792
+ })))
793
+ return {
794
+ mode: 'live',
795
+ address,
796
+ accountMode,
797
+ accountDex,
798
+ positionsCoverage: liveStates ? 'all-dexs' : 'selected-dex',
799
+ positionsObservedAt: liveStates ? wsState.at : Date.now(),
800
+ balanceSource: sharedBalance ? 'spot' : 'perp',
801
+ marginSummary: sharedBalance ? null : state.marginSummary,
802
+ withdrawable: sharedBalance ? null : Number(state.withdrawable),
803
+ usdcBalance,
804
+ unheldUsdc,
805
+ spotBalances: sharedBalance ? spot.balances : null,
806
+ tradeCapacity,
807
+ positions: assetPositions,
808
+ openOrders: open,
809
+ }
810
+ }
811
+
812
+ /** Open orders (live mode; paper has none resting). */
813
+ async orders(request) {
814
+ if (this.cfg.tradingMode === 'paper') return { mode: 'paper', orders: [] }
815
+ const address = await this.resolveAddress()
816
+ if (!address) throw new Error('live 模式需要钱包(config.privateKey 或 signer=web3)')
817
+ const rows = await this.client.frontendOpenOrders(address)
818
+ return { mode: 'live', orders: Array.isArray(rows) ? rows : [] }
819
+ }
820
+
821
+ /** Recent fills (live mode; paper reads the local ledger). */
822
+ async fills(request) {
823
+ if (this.cfg.tradingMode === 'paper') {
824
+ return { mode: 'paper', fills: this.store.paperAccount().fills.slice(0, 100) }
825
+ }
826
+ const address = await this.resolveAddress()
827
+ if (!address) throw new Error('live 模式需要钱包(config.privateKey 或 signer=web3)')
828
+ const rows = await this.client.userFills(address)
829
+ return { mode: 'live', fills: Array.isArray(rows) ? rows : [] }
830
+ }
831
+
832
+ /** Explicitly loaded history: these weighted API calls must not join the 3s account poll. */
833
+ async tradeHistory() {
834
+ if (this.cfg.tradingMode === 'paper') {
835
+ const paper = this.store.paperAccount()
836
+ return { mode: 'paper', orders: paper.orders.slice(0, 200), fills: paper.fills.slice(0, 200) }
837
+ }
838
+ const address = await this.resolveAddress()
839
+ if (!address) throw new Error('未配置交易主账户地址')
840
+ const [orders, fills] = await Promise.all([
841
+ this.client.historicalOrders(address), this.client.userFills(address),
842
+ ])
843
+ if (!Array.isArray(orders) || !Array.isArray(fills)) throw new Error('交易所历史数据响应无效')
844
+ const localByCloid = this.store.liveOrders
845
+ const decisions = this.store.liveDecisions
846
+ return { mode: 'live', orders: orders.slice(0, 2000).map((entry) => {
847
+ const order = entry.order ?? entry
848
+ const local = localByCloid[order.cloid]
849
+ return { ...order, status: entry.status ?? local?.status ?? 'unknown', statusTimestamp: entry.statusTimestamp,
850
+ reason: local?.thesis ?? (local?.decisionId ? decisions[local.decisionId]?.thesis ?? null : null) }
851
+ }), fills: fills.slice(0, 2000) }
852
+ }
853
+
854
+ /** Hyperliquid portfolio samples are display data, not an accounting ledger. */
855
+ async portfolio() {
856
+ if (this.cfg.tradingMode === 'paper') {
857
+ const account = await this.account({})
858
+ const fills = this.store.paperAccount().fills
859
+ return { mode: 'paper', equity: account.equity, unrealizedPnl: account.uPnl,
860
+ realizedPnl: fills.reduce((sum, fill) => sum + Number(fill.realized ?? 0), 0), series: {} }
861
+ }
862
+ const address = await this.resolveAddress()
863
+ if (!address) throw new Error('未配置交易主账户地址')
864
+ const rows = await this.client.portfolio(address)
865
+ if (!Array.isArray(rows)) throw new Error('交易所收益数据响应无效')
866
+ return { mode: 'live', series: Object.fromEntries(rows.filter((row) => Array.isArray(row) && typeof row[0] === 'string' && row[1])) }
867
+ }
868
+
869
+ async orderStatus(request) {
870
+ if (this.cfg.tradingMode !== 'live') throw new Error('paper 模式没有交易所订单状态')
871
+ const raw = request?.oid
872
+ const oid = typeof raw === 'string' && /^[1-9][0-9]*$/.test(raw) ? Number(raw) : raw
873
+ if (!(Number.isInteger(oid) && oid > 0) && !/^0x[0-9a-fA-F]{32}$/.test(String(oid ?? ''))) throw new Error('orderStatus 需要 oid 或 128 位 cloid')
874
+ const address = await this.resolveAddress()
875
+ if (!address) throw new Error('未配置交易主账户地址')
876
+ const exchange = await this.client.info('orderStatus', { user: address, oid })
877
+ const local = typeof oid === 'string' ? this.store.getLiveOrder(oid) : null
878
+ return { exchange, local }
879
+ }
880
+
881
+ // ── trading sessions ─────────────────────────────────────────────────
882
+ async sessions(request) {
883
+ return { sessions: this.store.listSessions() }
884
+ }
885
+
886
+ async registerSession(request) {
887
+ const sessionId = String(request?.sessionId ?? '').trim()
888
+ if (!sessionId) throw new Error('registerSession requires sessionId')
889
+ const row = this.store.registerSession(sessionId, { coin: request?.coin == null ? undefined : canonicalMarketCoin(request.coin), title: request?.title })
890
+ this.store.recordEvent('session.register', { sessionId })
891
+ return row
892
+ }
893
+
894
+ async removeSession(request) {
895
+ const sessionId = String(request?.sessionId ?? '').trim()
896
+ if (!sessionId) throw new Error('removeSession requires sessionId')
897
+ this.store.removeSession(sessionId)
898
+ return { ok: true }
899
+ }
900
+
901
+ async bindCoin(request) {
902
+ const sessionId = String(request?.sessionId ?? '').trim()
903
+ const coin = requireCoin(request)
904
+ const source = request?.source === 'ui' ? 'ui' : 'agent'
905
+ const clientId = source === 'ui' ? String(request?.clientId ?? '') : ''
906
+ // Commit the session binding before publishing the desk focus. An agent
907
+ // changing a background session must not move the visible desk.
908
+ const row = sessionId ? this.store.bindCoin(sessionId, coin) : null
909
+ if (source === 'ui') this.desk.sessionId = sessionId || null
910
+ if (!this.desk.sessionId || this.desk.sessionId === sessionId) {
911
+ this.desk.coin = coin
912
+ this.store.recordEvent('desk.state', { ...this.desk, source, clientId })
913
+ }
914
+ if (row) {
915
+ this.store.recordEvent('session.coin', { sessionId, coin, source, clientId })
916
+ return row
917
+ }
918
+ return { coin }
919
+ }
920
+
921
+ /**
922
+ * Desk focus two-way sync: the UI pushes what the user is looking at
923
+ * (coin/interval); the agent reads the same state through status() and the
924
+ * dynamic system-prompt section. Agent-driven changes (bindCoin) publish
925
+ * `desk.state` events the UI follows back.
926
+ */
927
+ async deskState(request) {
928
+ const patch = {}
929
+ if (request?.coin !== undefined) patch.coin = requireCoin(request)
930
+ if (request?.sessionId === null) this.desk.sessionId = null
931
+ if (request?.interval !== undefined) {
932
+ const iv = String(request.interval)
933
+ if (!INTERVALS.includes(iv)) throw new Error(`不支持的 K 线周期:${iv}`)
934
+ patch.interval = iv
935
+ }
936
+ if (patch.coin !== undefined && patch.coin !== this.desk.coin) {
937
+ this.desk.coin = patch.coin
938
+ this.store.recordEvent('desk.state', { ...this.desk, source: 'ui', clientId: String(request?.clientId ?? '') })
939
+ }
940
+ if (patch.interval !== undefined) this.desk.interval = patch.interval
941
+ return { ...this.desk }
942
+ }
943
+
944
+ // ── orders ───────────────────────────────────────────────────────────
945
+ /** Agent proposal: quote, account and hard limits are checked on the host. */
946
+ async prepareOrder(request, sessionId) {
947
+ if (this.cfg.tradingMode !== 'live') throw new Error('paper 下单无需 live 决策预检')
948
+ this.requireTradingSession(sessionId)
949
+ const thesis = String(request?.thesis ?? '').trim()
950
+ if (thesis.length < 10 || thesis.length > 1000) throw new Error('交易决策需要 10-1000 字的依据')
951
+ const order = orderOf(request)
952
+ const preview = await this.checkLiveOrder(order)
953
+ const decision = this.guard.create(order, sessionId, preview)
954
+ this.store.recordLiveDecision(decision.decisionId, { sessionId, order, thesis, preview, expiresAt: decision.expiresAt })
955
+ return decision
956
+ }
957
+
958
+ async checkLiveOrder(order) {
959
+ const client = this.client
960
+ const network = this.cfg.network
961
+ if ((order.takeProfitPx != null || order.stopLossPx != null) && this.cfg.network !== 'testnet') {
962
+ throw new Error('分组止盈止损尚待 funded testnet 验收,目前仅在 testnet 开放')
963
+ }
964
+ const address = await this.resolveAddress()
965
+ if (!address) throw new Error('live 交易需要有效的 tradingAddress 或本地钱包')
966
+ if (this.cfg.signer === 'web3') await this.web3Address()
967
+ const [market, mids, state, accountMode] = await Promise.all([
968
+ this.markets(), client.allMids(), client.clearinghouseState(address), this.accountMode(address, true),
969
+ ])
970
+ if (!Array.isArray(state?.assetPositions)) throw new Error('交易所持仓状态未确认,拒绝 live 下单')
971
+ const sharedBalance = accountMode === 'unifiedAccount' || accountMode === 'portfolioMargin'
972
+ const asset = market.universe.find((u) => u.name === order.coin)
973
+ if (!asset) throw new Error(`未知合约 ${order.coin}`)
974
+ const mid = Number(mids?.mids?.[order.coin] ?? mids?.[order.coin])
975
+ if (!Number.isFinite(mid) || mid <= 0) throw new Error('无法获取实时价格,拒绝 live 下单')
976
+ validateProtection(order, mid)
977
+ if (Number(wireSz(order.sz, asset.szDecimals)) !== order.sz) throw new Error(`数量不符合 ${order.coin} 的精度 ${asset.szDecimals}`)
978
+ const row = (state?.assetPositions ?? []).find((p) => p.position?.coin === order.coin)?.position
979
+ const otherExposureUsd = (state?.assetPositions ?? []).reduce((sum, p) => {
980
+ if (p.position?.coin === order.coin) return sum
981
+ const value = Number(p.position?.positionValue)
982
+ if (!Number.isFinite(value)) throw new Error('无法读取其他仓位敞口')
983
+ return sum + Math.abs(value)
984
+ }, 0)
985
+ const positionSize = Number(row?.szi ?? 0)
986
+ const leverage = Number(row?.leverage?.value ?? row?.leverage?.leverage ?? 1)
987
+ let accountValue = Number(state?.marginSummary?.accountValue)
988
+ if (sharedBalance) {
989
+ const [spot, capacity] = await Promise.all([
990
+ client.spotClearinghouseState(address), client.activeAssetData(address, order.coin),
991
+ ])
992
+ if (!Array.isArray(spot?.balances) || !validTradeCapacity(capacity, order.coin)) throw new Error('统一账户余额或可交易额度未确认,拒绝 live 下单')
993
+ // Core perps use USDC collateral. Other eligible portfolio assets cannot
994
+ // be valued as dollars here; this bound may reject a funded portfolio.
995
+ accountValue = Number(spot.balances.find((balance) => balance.coin === 'USDC' && balance.token === 0)?.total ?? 0)
996
+ const sideCapacity = Math.min(...capacity.maxTradeSzs.map(Number))
997
+ if (!order.reduceOnly && order.sz > sideCapacity + 1e-10) throw new Error('订单数量超过交易所当前可交易额度')
998
+ }
999
+ const riskPrice = order.type === 'limit' ? Math.max(mid * 1.05, order.px) : mid * 1.05
1000
+ const checked = this.guard.validate({ ...order, price: riskPrice, positionSize, otherExposureUsd, accountValue, leverage })
1001
+ if (client !== this.client || network !== this.cfg.network) throw new Error('网络已切换,请重新预检交易')
1002
+ return { coin: order.coin, side: order.isBuy ? 'buy' : 'sell', mid, riskPrice, accountValue, accountMode, positionSize, otherExposureUsd, leverage, ...checked, network, address }
1003
+ }
1004
+
1005
+ async executeToolOrder(request, exec, sessionId) {
1006
+ this.requireTradingSession(sessionId)
1007
+ return this.placeOrder({ ...request, [TOOL_CALL]: { exec, sessionId } })
1008
+ }
1009
+
1010
+ requireTradingSession(sessionId) {
1011
+ if (!sessionId || !this.store.getSession(sessionId)) throw new Error('交易操作必须来自已登记的交易会话')
1012
+ }
1013
+
1014
+ async authorizeToolAction(reason, exec) {
1015
+ if (!exec?.agent) throw new Error('live 签名仅允许从 DSH agent 工具调用')
1016
+ const outcome = await this.approvalGateway().request({ toolName: 'dsh-hyperliquid', callId: exec.callId, agent: exec.agent, signal: exec.signal, reason })
1017
+ if (outcome !== 'allowed-once') throw new Error(`用户未批准 live 操作(${outcome})`)
1018
+ }
1019
+
1020
+ /**
1021
+ * THE order path — every order (tool or UI) goes through here.
1022
+ * Wire contract (research §3.2/§4.4): orders carry SHORT keys
1023
+ * `{a, b, p, s, r, t}` where `a` is the universe index and px/sz are
1024
+ * decimal STRINGS with trailing zeros stripped; market orders are Ioc
1025
+ * limit orders at ±5% by default.
1026
+ * @returns paper: the fill; live: the exchange response
1027
+ */
1028
+ async placeOrder(request) {
1029
+ const coin = requireCoin(request)
1030
+ if (typeof request?.isBuy !== 'boolean') throw new Error('place 需要明确的 isBuy 布尔值')
1031
+ const isBuy = request.isBuy
1032
+ const sz = Number(request?.sz)
1033
+ if (!Number.isFinite(sz) || sz <= 0) throw new Error('下单数量 sz 必须为正数')
1034
+ const reduceOnly = Boolean(request?.reduceOnly)
1035
+ if (this.cfg.tradingMode === 'paper') {
1036
+ if (request?.takeProfitPx != null || request?.stopLossPx != null) throw new Error('paper 暂不模拟止盈止损触发单;请移除保护价格或切换 testnet live')
1037
+ const { universe } = await this.markets()
1038
+ if (!universe.some((row) => row.name === coin)) throw new Error(`当前仅支持核心永续合约模拟交易:${coin}`)
1039
+ const mid = await this.midOf(coin)
1040
+ const pos = this.store.paper.positions[coin]?.size ?? 0
1041
+ if (reduceOnly && (pos === 0 || Math.sign(isBuy ? sz : -sz) === Math.sign(pos) || sz > Math.abs(pos) + 1e-10)) throw new Error('只减仓订单会增加或翻转仓位')
1042
+ if (request?.type === 'limit') {
1043
+ const limit = Number(request?.px)
1044
+ if (!Number.isFinite(limit) || limit <= 0) throw new Error('限价单需要有效 px')
1045
+ if ((isBuy && limit < mid) || (!isBuy && limit > mid)) throw new Error('paper 当前只支持可立即成交的限价单')
1046
+ }
1047
+ const slip = Number(request?.slippagePct)
1048
+ const pct = Number.isFinite(slip) ? Math.min(Math.max(slip, 0), 10) / 100 : 0.0005
1049
+ const px = mid * (isBuy ? 1 + pct : 1 - pct)
1050
+ if (request?.type === 'limit' && (isBuy ? px > Number(request.px) : px < Number(request.px))) throw new Error('paper 模拟滑点后无法满足限价')
1051
+ const paper = this.store.paperAccount()
1052
+ const risk = paperRiskState(paper, { ...await safeMids(this), [coin]: px }, true)
1053
+ const positionSize = Number(paper.positions[coin]?.size ?? 0)
1054
+ const leverage = Number(paper.positions[coin]?.leverage ?? paper.leverageByCoin?.[coin] ?? 1)
1055
+ const otherExposureUsd = risk.positions.reduce((sum, p) => sum + (p.coin === coin ? 0 : Math.abs(p.size) * p.markPx), 0)
1056
+ const guard = this.paperGuard ??= new TradingGuard(this.cfg.dataDir, { ...this.cfg, dataDir: this.cfg.dataDir }, { budgetName: this.cfg.network === 'mainnet' ? 'paper-budget-mainnet.json' : 'paper-budget.json' })
1057
+ const checked = guard.validate({ coin, isBuy, sz, price: px, positionSize, otherExposureUsd, accountValue: risk.equity, leverage, reduceOnly })
1058
+ const currentMargin = Math.abs(positionSize) * px / leverage
1059
+ const projectedMargin = risk.marginUsed - currentMargin + Math.abs(checked.nextSize) * px / leverage
1060
+ if (!checked.riskReducing && projectedMargin > risk.equity + 1e-8) throw new Error('模拟账户可用保证金不足')
1061
+ const budgetTicket = guard.reserve(checked.notionalUsd, { riskReducing: checked.riskReducing })
1062
+ let fill
1063
+ try { fill = this.store.paperFill({ coin, isBuy, sz, px, reason: request?.reason ?? 'chat' }) }
1064
+ catch (err) { guard.release(budgetTicket); throw err }
1065
+ this.store.paperOrderRecord({ coin, isBuy, sz, px: request?.type === 'limit' ? Number(request.px) : px,
1066
+ filledPx: px, type: request?.type === 'limit' ? 'limit' : 'market', reduceOnly,
1067
+ reason: request?.reason ?? '' })
1068
+ return { mode: 'paper', fill, marginUsed: projectedMargin, availableMargin: Math.max(0, risk.equity - projectedMargin),
1069
+ simulation: '固定滑点;未模拟盘口深度、手续费、资金费或强平' }
1070
+ }
1071
+ // live
1072
+ const trusted = request?.[TOOL_CALL]
1073
+ if (!trusted?.exec?.agent) throw new Error('live 下单仅允许从 DSH agent 工具调用')
1074
+ const orderSpec = orderOf(request)
1075
+ const preview = this.guard.consume(request?.decisionId, orderSpec, trusted.sessionId)
1076
+ if (preview.network !== this.cfg.network) throw new Error('交易决策的网络与当前网络不一致,请重新预检')
1077
+ const fresh = await this.checkLiveOrder(orderSpec)
1078
+ if (Math.abs(fresh.mid / preview.mid - 1) > 0.01) throw new Error('价格已偏离预检超过 1%,请重新决策')
1079
+ if (!this.cfg.autonomousLive) {
1080
+ await this.authorizeToolAction(`Hyperliquid ${this.cfg.network} ${coin} ${isBuy ? '买入' : '卖出'} ${sz},预估名义价值 ${fresh.notionalUsd.toFixed(2)} USD。是否允许本次交易?`, trusted.exec)
1081
+ }
1082
+ const { universe } = await this.markets()
1083
+ const idx = universe.findIndex((u) => u.name === coin)
1084
+ if (idx < 0) throw new Error(`未知合约 ${coin}`)
1085
+ const szDecimals = universe[idx].szDecimals
1086
+ const tif = request?.type === 'limit' ? (request?.tif === 'Alo' ? 'Alo' : 'Gtc') : 'Ioc'
1087
+ const mid = await this.midOf(coin)
1088
+ let pxNum
1089
+ if (request?.type === 'limit') {
1090
+ pxNum = Number(request?.px)
1091
+ if (!Number.isFinite(pxNum) || pxNum <= 0) throw new Error('限价单需要有效 px')
1092
+ } else {
1093
+ // Market = Ioc limit; docs/SDK use a 5% slippage collar.
1094
+ pxNum = mid * (isBuy ? 1.05 : 0.95)
1095
+ }
1096
+ // Exchange rule: minimum order notional is $10 (rejected otherwise).
1097
+ const notional = pxNum * sz
1098
+ if (!(notional >= 10)) {
1099
+ throw new Error(`订单名义价值 ${notional.toFixed(2)} USD 低于 Hyperliquid 最低 10 USD`)
1100
+ }
1101
+ const order = {
1102
+ a: idx,
1103
+ b: isBuy,
1104
+ p: wirePx(pxNum, szDecimals),
1105
+ s: wireSz(sz, szDecimals),
1106
+ r: reduceOnly,
1107
+ t: { limit: { tif } },
1108
+ c: '0x' + request.decisionId.replaceAll('-', ''),
1109
+ }
1110
+ const children = protectionOrders(orderSpec, idx, szDecimals, order.s)
1111
+ if (children.length) {
1112
+ const triggerOf = (kind) => {
1113
+ const child = children.find((row) => row.t.trigger.tpsl === kind)
1114
+ return child ? Number(child.t.trigger.triggerPx) : null
1115
+ }
1116
+ validateProtection({ ...orderSpec, px: orderSpec.type === 'limit' ? Number(order.p) : null,
1117
+ takeProfitPx: triggerOf('tp'), stopLossPx: triggerOf('sl') }, mid)
1118
+ }
1119
+ const action = { type: 'order', orders: [order, ...children], grouping: children.length ? 'normalTpsl' : 'na' }
1120
+ const budgetTicket = this.guard.reserve(fresh.notionalUsd, { riskReducing: fresh.riskReducing })
1121
+ this.store.recordLiveOrder(order.c, { status: 'submitting', coin, isBuy, sz, sessionId: trusted.sessionId,
1122
+ decisionId: request.decisionId, thesis: this.store.liveDecisions?.[request.decisionId]?.thesis ?? null,
1123
+ protection: { takeProfitPx: orderSpec.takeProfitPx, stopLossPx: orderSpec.stopLossPx }, at: Date.now() })
1124
+ let res
1125
+ try {
1126
+ res = await this.submitAction(action, trusted.exec)
1127
+ } catch (err) {
1128
+ this.store.recordLiveOrder(order.c, { status: 'unknown', error: String(err?.message ?? err) })
1129
+ throw new Error(`订单状态未确认;请先用 hyper_account action=orderStatus, oid=${order.c} 查询,勿直接重下。原因:${String(err?.message ?? err)}`)
1130
+ }
1131
+ try {
1132
+ assertExchangeAccepted(res)
1133
+ if (children.length && res?.response?.data?.statuses?.length !== action.orders.length) throw new Error('保护单回执不完整')
1134
+ } catch (err) {
1135
+ const parent = res?.response?.data?.statuses?.[0]
1136
+ if ((parent && !parent.error && (parent.resting || parent.filled)) || (children.length && res?.status === 'ok')) {
1137
+ this.store.recordLiveOrder(order.c, { status: 'protection-unknown', response: res })
1138
+ throw new Error(`主单可能已生效,但保护单未全部确认;请立即核对挂单、持仓和订单 ${order.c},勿直接重下。原因:${String(err?.message ?? err)}`)
1139
+ }
1140
+ this.guard.release(budgetTicket)
1141
+ this.store.recordLiveOrder(order.c, { status: 'rejected', response: res })
1142
+ throw err
1143
+ }
1144
+ const parentFilledSz = Number(res?.response?.data?.statuses?.[0]?.filled?.totalSz)
1145
+ const protectionState = children.length
1146
+ ? parentFilledSz >= Number(order.s) - 1e-10 ? 'verify-active' : 'parent-not-fully-filled'
1147
+ : null
1148
+ this.store.recordLiveOrder(order.c, { status: 'accepted', protectionState, response: res })
1149
+ return { mode: 'live', cloid: order.c, protectionState, response: res }
1150
+ }
1151
+
1152
+ async cancelOrder(request) {
1153
+ const coin = requireCoin(request)
1154
+ const oid = Number(request?.oid)
1155
+ if (!Number.isInteger(oid) || oid <= 0) throw new Error('cancelOrder 需要有效的 oid')
1156
+ if (this.cfg.tradingMode === 'paper') throw new Error('paper 模式没有挂单')
1157
+ const exec = request?.[TOOL_CALL]?.exec
1158
+ if (!exec?.agent) throw new Error('live 撤单仅允许从 DSH agent 工具调用')
1159
+ if (!this.cfg.autonomousLive || !this.cfg.allowedCoins.includes(coin)) await this.authorizeToolAction(`Hyperliquid ${this.cfg.network} 撤销 ${coin} 订单 oid=${oid}?`, exec)
1160
+ const { universe } = await this.markets()
1161
+ const idx = universe.findIndex((u) => u.name === coin)
1162
+ if (idx < 0) throw new Error(`未知合约 ${coin}`)
1163
+ const res = await this.submitAction({ type: 'cancel', cancels: [{ a: idx, o: oid }] }, exec)
1164
+ assertExchangeAccepted(res)
1165
+ this.store.recordEvent('live.cancel', { coin, oid })
1166
+ return { mode: 'live', response: res }
1167
+ }
1168
+
1169
+ async setLeverage(request) {
1170
+ const coin = requireCoin(request)
1171
+ const leverage = Number(request?.leverage)
1172
+ if (!Number.isInteger(leverage) || leverage < 1 || leverage > 50) throw new Error('leverage 需要是 1-50 的整数')
1173
+ const { universe } = await this.markets()
1174
+ const idx = universe.findIndex((u) => u.name === coin)
1175
+ if (idx < 0) throw new Error(`未知合约 ${coin}`)
1176
+ if (leverage > Number(universe[idx].maxLeverage ?? 50)) throw new Error(`杠杆超过 ${coin} 市场上限 ${universe[idx].maxLeverage}x`)
1177
+ const isCross = request?.marginMode !== 'isolated'
1178
+ if (this.cfg.tradingMode === 'paper') {
1179
+ if (leverage > this.cfg.maxLeverage) throw new Error(`杠杆超过配置上限 ${this.cfg.maxLeverage}x`)
1180
+ if (!this.cfg.allowedCoins.includes(coin)) throw new Error(`${coin} 不在允许交易币对内`)
1181
+ const paper = this.store.paperAccount()
1182
+ if (paper.positions[coin]) {
1183
+ const risk = paperRiskState(paper, await safeMids(this), true)
1184
+ const pos = paper.positions[coin]
1185
+ const marked = risk.positions.find((row) => row.coin === coin)
1186
+ const currentMargin = Math.abs(pos.size) * marked.markPx / marked.leverage
1187
+ const projectedMargin = risk.marginUsed - currentMargin + Math.abs(pos.size) * marked.markPx / leverage
1188
+ if (projectedMargin > risk.equity + 1e-8) throw new Error('降低杠杆后模拟账户保证金不足')
1189
+ }
1190
+ this.store.paperSetLeverage(coin, leverage)
1191
+ return { mode: 'paper', ok: true }
1192
+ }
1193
+ if (leverage > this.cfg.maxLeverage) throw new Error(`杠杆超过配置上限 ${this.cfg.maxLeverage}x`)
1194
+ if (!this.cfg.allowedCoins.includes(coin)) throw new Error(`交易限额:${coin} 不在允许交易币对内(allowedCoins)`)
1195
+ const exec = request?.[TOOL_CALL]?.exec
1196
+ if (!exec?.agent) throw new Error('live 杠杆变更仅允许从 DSH agent 工具调用')
1197
+ if (!this.cfg.autonomousLive) await this.authorizeToolAction(`Hyperliquid ${this.cfg.network} 将 ${coin} 杠杆设为 ${leverage}x?`, exec)
1198
+ const res = await this.submitAction({ type: 'updateLeverage', asset: idx, isCross, leverage }, exec)
1199
+ assertExchangeAccepted(res)
1200
+ this.store.recordEvent('live.leverage', { coin, leverage, isCross })
1201
+ return { mode: 'live', response: res }
1202
+ }
1203
+
1204
+ executeToolCancel(request, exec) {
1205
+ this.requireTradingSession(exec?.agent?.id ?? exec?.agent?.session?.id)
1206
+ return this.cancelOrder({ ...request, [TOOL_CALL]: { exec } })
1207
+ }
1208
+ executeToolLeverage(request, exec) {
1209
+ this.requireTradingSession(exec?.agent?.id ?? exec?.agent?.session?.id)
1210
+ return this.setLeverage({ ...request, [TOOL_CALL]: { exec } })
1211
+ }
1212
+
1213
+ /** UI event ring replay (order placed, fills, session changes). */
1214
+ async eventsSince(request) {
1215
+ return this.store.eventsSince(Number(request?.seq) || 0)
1216
+ }
1217
+
1218
+ /** Stop the WS loop on teardown. */
1219
+ stopWs() {
1220
+ clearInterval(this.sweepTimer)
1221
+ this.client.close()
1222
+ }
1223
+ }
1224
+
1225
+ // ── helpers ────────────────────────────────────────────────────────────
1226
+ function requireCoin(request) {
1227
+ const coin = canonicalMarketCoin(request?.coin)
1228
+ if (!coin) throw new Error('需要 coin(如 BTC / ETH)')
1229
+ return coin
1230
+ }
1231
+
1232
+ const INTERVALS = ['1m', '3m', '5m', '15m', '30m', '1h', '2h', '4h', '8h', '12h', '1d', '3d', '1w', '1M']
1233
+
1234
+ function requireInterval(value) {
1235
+ const interval = String(value)
1236
+ if (!INTERVALS.includes(interval)) throw new Error(`不支持的 K 线周期:${interval}`)
1237
+ return interval
1238
+ }
1239
+
1240
+ function orderOf(request) {
1241
+ return {
1242
+ coin: requireCoin(request),
1243
+ isBuy: request?.isBuy,
1244
+ sz: Number(request?.sz),
1245
+ type: request?.type === 'limit' ? 'limit' : 'market',
1246
+ px: request?.type === 'limit' ? Number(request?.px) : null,
1247
+ tif: request?.tif === 'Alo' ? 'Alo' : 'Gtc',
1248
+ reduceOnly: request?.reduceOnly === true,
1249
+ takeProfitPx: request?.takeProfitPx == null ? null : Number(request.takeProfitPx),
1250
+ stopLossPx: request?.stopLossPx == null ? null : Number(request.stopLossPx),
1251
+ }
1252
+ }
1253
+
1254
+ function validateProtection(order, mid) {
1255
+ const tp = order.takeProfitPx
1256
+ const sl = order.stopLossPx
1257
+ if (tp == null && sl == null) return
1258
+ if (order.reduceOnly) throw new Error('只减仓主单不能附加新的止盈止损保护单')
1259
+ const entry = order.type === 'limit' ? order.px : mid
1260
+ if (!Number.isFinite(entry) || entry <= 0) throw new Error('保护单需要有效的主单价格')
1261
+ const upper = Math.max(entry, mid)
1262
+ const lower = Math.min(entry, mid)
1263
+ if (tp != null && (!Number.isFinite(tp) || tp <= 0 || (order.isBuy ? tp <= upper : tp >= lower))) throw new Error('止盈价必须位于入场价与当前价的盈利方向之外')
1264
+ if (sl != null && (!Number.isFinite(sl) || sl <= 0 || (order.isBuy ? sl >= lower : sl <= upper))) throw new Error('止损价必须位于入场价与当前价的止损方向之外')
1265
+ }
1266
+
1267
+ function protectionOrders(order, asset, szDecimals, size) {
1268
+ const children = []
1269
+ for (const [kind, trigger] of [['tp', order.takeProfitPx], ['sl', order.stopLossPx]]) {
1270
+ if (trigger == null) continue
1271
+ const triggerPx = wirePx(trigger, szDecimals)
1272
+ if (Number(triggerPx) <= 0) throw new Error(`${kind} 触发价精度无效`)
1273
+ children.push({
1274
+ a: asset, b: !order.isBuy, p: triggerPx, s: size, r: true,
1275
+ t: { trigger: { isMarket: true, triggerPx, tpsl: kind } },
1276
+ })
1277
+ }
1278
+ return children
1279
+ }
1280
+
1281
+ function assertExchangeAccepted(response) {
1282
+ if (response?.status !== 'ok') throw new Error(`Hyperliquid 拒绝操作:${JSON.stringify(response).slice(0, 500)}`)
1283
+ const statuses = response?.response?.data?.statuses
1284
+ const rejected = Array.isArray(statuses) ? statuses.find((s) => s?.error) : null
1285
+ if (rejected) throw new Error(`Hyperliquid 拒单:${rejected.error}`)
1286
+ }
1287
+
1288
+ /**
1289
+ * float_to_wire equivalent (SDK signing.py): format to 8 decimals, strip
1290
+ * trailing zeros, normalize -0 → 0. Prices are additionally capped to 5
1291
+ * significant figures with ≤ (6 − szDecimals) decimals for perps.
1292
+ */
1293
+ function wireStr(value) {
1294
+ let s = Number(value).toFixed(8)
1295
+ if (s.includes('.')) s = s.replace(/0+$/, '').replace(/\.$/, '')
1296
+ if (s === '-0') s = '0'
1297
+ return s
1298
+ }
1299
+
1300
+ function wirePx(pxNum, szDecimals) {
1301
+ const capped = Number(sigFigs(pxNum, 5, Math.max(0, 6 - szDecimals)))
1302
+ return wireStr(capped)
1303
+ }
1304
+
1305
+ function wireSz(szNum, szDecimals) {
1306
+ return wireStr(Number(szNum.toFixed(szDecimals)))
1307
+ }
1308
+
1309
+ function validTradeCapacity(value, coin) {
1310
+ return value?.coin === coin && ['availableToTrade', 'maxTradeSzs'].every((field) =>
1311
+ Array.isArray(value[field]) && value[field].length === 2 && value[field].every((part) => Number.isFinite(Number(part)) && Number(part) >= 0))
1312
+ }
1313
+
1314
+ function expandHome(p) {
1315
+ if (p === '~') return homedir()
1316
+ if (p?.startsWith('~/')) return resolve(homedir(), p.slice(2))
1317
+ return resolve(p)
1318
+ }
1319
+
1320
+ function defaultSpanMs(interval) {
1321
+ const spans = { '1m': 6 * 3600e3, '3m': 18 * 3600e3, '5m': 24 * 3600e3, '15m': 3 * 86400e3, '30m': 6 * 86400e3, '1h': 7 * 86400e3, '2h': 14 * 86400e3, '4h': 30 * 86400e3, '8h': 60 * 86400e3, '12h': 90 * 86400e3, '1d': 180 * 86400e3, '3d': 540 * 86400e3, '1w': 1095 * 86400e3, '1M': 5475 * 86400e3 }
1322
+ return spans[interval] ?? 7 * 86400e3
1323
+ }
1324
+
1325
+ function argsOf(key) {
1326
+ const sep = key.indexOf(':')
1327
+ const head = sep < 0 ? key : key.slice(0, sep)
1328
+ const rest = sep < 0 ? '' : key.slice(sep + 1)
1329
+ if (head === 'allMids') return { type: 'allMids' }
1330
+ if (head === 'allDexsAssetCtxs') return { type: 'allDexsAssetCtxs' }
1331
+ if (head === 'allDexsClearinghouseState') return { type: 'allDexsClearinghouseState', user: rest }
1332
+ if (head === 'l2Book') return { type: 'l2Book', coin: rest }
1333
+ if (head === 'l2BookGrouped') {
1334
+ const parts = rest.match(/^(.+):([2345]):([025])$/)
1335
+ if (!parts) throw new Error(`invalid grouped book key: ${key}`)
1336
+ const nSigFigs = Number(parts[2]); const mantissa = Number(parts[3])
1337
+ return { type: 'l2Book', coin: decodeURIComponent(parts[1]), nSigFigs, ...(mantissa ? { mantissa } : {}) }
1338
+ }
1339
+ if (head === 'trades') return { type: 'trades', coin: rest }
1340
+ if (head === 'candle') {
1341
+ const last = rest.lastIndexOf(':')
1342
+ return { type: 'candle', coin: rest.slice(0, last), interval: rest.slice(last + 1) }
1343
+ }
1344
+ throw new Error(`unknown snapshot key: ${key}`)
1345
+ }
1346
+
1347
+ async function safeMids(remote) {
1348
+ const cached = remote.client.snapshot(keys.allMids())
1349
+ if (cached?.data && Date.now() - cached.at < 5_000) return cached.data.mids ?? cached.data
1350
+ try {
1351
+ const res = await remote.client.allMids()
1352
+ return res?.mids ?? res ?? {}
1353
+ } catch {
1354
+ return {}
1355
+ }
1356
+ }
1357
+
1358
+ /** Mark every paper position before computing equity and occupied margin. */
1359
+ function paperRiskState(paper, mids, strict = false) {
1360
+ let uPnl = 0
1361
+ let marginUsed = 0
1362
+ let marksConfirmed = true
1363
+ const positions = Object.entries(paper.positions ?? {}).map(([coin, p]) => {
1364
+ const liveMark = Number(mids?.[coin])
1365
+ const validMark = Number.isFinite(liveMark) && liveMark > 0
1366
+ if (!validMark) {
1367
+ if (strict) throw new Error(`${coin} 实时价格未确认,拒绝模拟交易`)
1368
+ marksConfirmed = false
1369
+ }
1370
+ const markPx = validMark ? liveMark : Number(p.entryPx)
1371
+ const size = Number(p.size)
1372
+ const entryPx = Number(p.entryPx)
1373
+ const leverage = Number(p.leverage ?? paper.leverageByCoin?.[coin] ?? 1)
1374
+ if (!(Number.isFinite(markPx) && markPx > 0 && Number.isFinite(size) && Number.isFinite(entryPx) && entryPx > 0 &&
1375
+ Number.isFinite(leverage) && leverage >= 1)) throw new Error('模拟账户持仓数据无效')
1376
+ const positionPnl = (markPx - entryPx) * size
1377
+ uPnl += positionPnl
1378
+ marginUsed += Math.abs(size) * markPx / leverage
1379
+ return { coin, size, entryPx, markPx, uPnl: positionPnl, leverage }
1380
+ })
1381
+ const equity = Number(paper.usd) + uPnl
1382
+ if (!Number.isFinite(equity)) throw new Error('模拟账户权益无效')
1383
+ return { equity, uPnl, marginUsed, marksConfirmed, positions }
1384
+ }
1385
+
1386
+ // Remote markers: every method the web client may call.
1387
+ for (const m of [
1388
+ 'status', 'switchNetwork', 'switchTradingMode', 'markets', 'marketCatalog', 'marketDetail', 'chartMarks', 'drawChartMark', 'eraseChartMark', 'sessionWorkspace', 'updateSessionWorkspace', 'snapshot', 'candles', 'fundingHistory', 'account', 'fills', 'tradeHistory', 'portfolio',
1389
+ 'sessions', 'registerSession', 'removeSession', 'bindCoin', 'deskState',
1390
+ 'eventsSince',
1391
+ ]) {
1392
+ markRemoteMethod(HyperRemote.prototype, m)
1393
+ }