dsh-hyperliquid 0.2.0 → 0.4.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 CHANGED
@@ -11,11 +11,14 @@ import { resolve } from 'node:path'
11
11
  import { HyperliquidClient, keys, sigFigs } from './hl.js'
12
12
  import { canonicalMarketCoin } from './market-symbol.js'
13
13
  import { HyperStore } from './store.js'
14
- import { addressFromPrivateKey, buildL1TypedData, splitSignature } from './signing.js'
14
+ import { addressFromPrivateKey, buildL1TypedData, buildSendAsset, signTypedDataLocal, splitSignature } from './signing.js'
15
15
  import { TradingGuard } from './trading-guard.js'
16
16
  import { normalizeMaLines } from './moving-average.js'
17
17
  import { ALERT_KINDS, ICON_NAMES, TEXT_KINDS, drawingGeometry, drawingStyle, isIconLabel, linePriceAt } from './chart-drawings.js'
18
18
  import { normalizeRiskSettings } from './risk-settings.js'
19
+ import { INDICATOR_IDS, normalizeIndicatorSettings } from './studies.js'
20
+ import { normalizeResearch } from './workspace-settings.js'
21
+ import { fundingWindow } from './review.js'
19
22
 
20
23
  const TOOL_CALL = Symbol('verified DSH tool call')
21
24
 
@@ -125,12 +128,16 @@ export class HyperRemote extends TypertRemoteService {
125
128
  if (this.cfg.tradingAddress && !/^0x[0-9a-fA-F]{40}$/.test(this.cfg.tradingAddress)) throw new Error('tradingAddress 必须为 20 字节 EVM 地址')
126
129
  this.client = new HyperliquidClient({ network: this.cfg.network, privateKey: this.cfg.privateKey })
127
130
  this.store = new HyperStore(this.cfg.dataDir, this.cfg.network)
131
+ // Signing chosen on the desk wins over plugin config (set without a restart; see updateWalletSettings).
132
+ try { Object.assign(this.cfg, normalizeWalletSettings(this.store.walletSettings() ?? {}, this.cfg)) } catch { /* a bad saved file falls back to config */ }
128
133
  this.baseRiskSettings = Object.fromEntries(['allowedCoins', 'maxOrderUsd', 'maxDailyUsd', 'maxPositionUsd', 'maxLeverage'].map((key) => [key, this.cfg[key]]))
129
134
  const savedRiskSettings = this.store.getRiskSettings()
130
135
  this.riskSettingsOverride = savedRiskSettings != null
131
136
  if (savedRiskSettings) Object.assign(this.cfg, normalizeRiskSettings(savedRiskSettings))
132
137
  this.guard = new TradingGuard(this.cfg.dataDir, { ...this.cfg, dataDir: this.cfg.dataDir })
133
138
  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' })
139
+ // Custody: the desk setting wins; before the trader picks one, config.autonomousLive implies managed.
140
+ this.cfg.custody = this.store.custody() ?? (this.cfg.autonomousLive ? 'managed' : 'assisted')
134
141
  this.marketsCache = null
135
142
  this.marketsAt = 0
136
143
  this.catalogCache = null
@@ -143,12 +150,13 @@ export class HyperRemote extends TypertRemoteService {
143
150
  this.tradeCapacityCache = new Map()
144
151
  this.tradeCapacityPending = new Map()
145
152
  this.walletAddressCache = null
153
+ this.walletEpoch = 0 // bumped on every wallet switch; work that started under an older wallet is discarded
146
154
  this.desk = { coin: 'BTC', interval: '15m', sessionId: null } // active UI session and market focus
147
155
  this.lastTouch = new Map() // snapshot key -> last request ms
148
156
  this.activeTradeOps = 0
149
157
  // Hold the network stable through every order/cancel/leverage operation,
150
158
  // including wallet approval and the exchange response.
151
- for (const method of ['placeOrder', 'cancelOrder', 'setLeverage']) {
159
+ for (const method of ['placeOrder', 'cancelOrder', 'setLeverage', 'transferCollateral']) {
152
160
  const operation = this[method].bind(this)
153
161
  this[method] = async (...args) => {
154
162
  this.activeTradeOps += 1
@@ -159,7 +167,12 @@ export class HyperRemote extends TypertRemoteService {
159
167
  this.sweepTimer = setInterval(() => this.sweepSubs(), SUB_SWEEP_MS)
160
168
  this.sweepTimer.unref?.()
161
169
  this.alertSides = new Map() // drawing id → last side of the mid price (-1 below, 1 above)
162
- this.alertTimer = setInterval(() => { try { this.checkDrawingAlerts() } catch { /* next tick retries */ } }, ALERT_CHECK_MS)
170
+ this.alertListeners = new Set() // (event) => void — the host wakes the session agent through this
171
+ this.dexMidCache = new Map() // HIP-3 dex → { at, mids }: core allMids does not carry builder-deployed markets
172
+ this.alertTimer = setInterval(() => {
173
+ try { this.checkDrawingAlerts() } catch { /* next tick retries */ }
174
+ void this.refreshAlertDexMids().catch(() => {})
175
+ }, ALERT_CHECK_MS)
163
176
  this.alertTimer.unref?.()
164
177
  }
165
178
 
@@ -291,8 +304,63 @@ export class HyperRemote extends TypertRemoteService {
291
304
  return this.client.exchangeSigned(action, signature, nonce)
292
305
  }
293
306
 
307
+ /**
308
+ * The exchange asset behind a tradable perp: core perps use their `meta` index; HIP-3 (builder-deployed)
309
+ * perps use 100000 + perpDexIndex * 10000 + index in that dex's meta (Hyperliquid "Asset IDs").
310
+ * Only USDC-collateral dexes are tradable here, since balances and limits are valued in USD.
311
+ * @returns {{ coin, asset, dex, szDecimals, maxLeverage, onlyIsolated }}
312
+ */
313
+ async tradeAsset(coin) {
314
+ if (!coin.includes(':')) {
315
+ const { universe } = await this.markets()
316
+ const index = universe.findIndex((row) => row.name === coin)
317
+ if (index < 0) throw new Error(`未知合约 ${coin};现货与 Outcome 市场目前只读`)
318
+ const row = universe[index]
319
+ return { coin, asset: index, dex: '', szDecimals: row.szDecimals, maxLeverage: row.maxLeverage, onlyIsolated: Boolean(row.onlyIsolated) }
320
+ }
321
+ const dex = coin.split(':')[0]
322
+ const client = this.client
323
+ const cached = this.dexAssets?.get(dex)
324
+ let entry = cached && Date.now() - cached.at < 60_000 && cached.network === this.cfg.network ? cached : null
325
+ if (!entry) {
326
+ const [dexs, meta] = await Promise.all([this.perpDexList(), client.info('meta', { dex })])
327
+ if (client !== this.client) throw new Error('网络已切换,请重新预检交易')
328
+ const dexIndex = dexs.findIndex((row) => row?.name === dex)
329
+ if (dexIndex < 1 || !Array.isArray(meta?.universe)) throw new Error(`未知的 HIP-3 交易所 ${dex}`)
330
+ entry = { at: Date.now(), network: this.cfg.network, dexIndex, collateralToken: meta.collateralToken, universe: meta.universe }
331
+ ;(this.dexAssets ??= new Map()).set(dex, entry)
332
+ }
333
+ if (entry.collateralToken !== 0) throw new Error(`${dex} 使用非 USDC 保证金(token ${entry.collateralToken}),暂只支持查看行情`)
334
+ const index = entry.universe.findIndex((row) => row.name === coin)
335
+ if (index < 0) throw new Error(`${dex} 上没有合约 ${coin}`)
336
+ const row = entry.universe[index]
337
+ if (row.isDelisted) throw new Error(`${coin} 已下架`)
338
+ return { coin, asset: 100000 + entry.dexIndex * 10000 + index, dex, szDecimals: row.szDecimals, maxLeverage: row.maxLeverage,
339
+ onlyIsolated: Boolean(row.onlyIsolated) || row.marginMode === 'strictIsolated' || row.marginMode === 'noCross' }
340
+ }
341
+
342
+ /** `perpDexs`: index 0 is the core dex (null), HIP-3 dexes follow in asset-id order. */
343
+ async perpDexList() {
344
+ if (this.perpDexCache && Date.now() - this.perpDexCache.at < 300_000 && this.perpDexCache.network === this.cfg.network) return this.perpDexCache.rows
345
+ const rows = await this.client.info('perpDexs')
346
+ if (!Array.isArray(rows)) throw new Error('无法读取 HIP-3 交易所列表')
347
+ this.perpDexCache = { at: Date.now(), network: this.cfg.network, rows }
348
+ return rows
349
+ }
350
+
351
+ /** Mids of one HIP-3 dex (core mids do not include builder-deployed markets). */
352
+ async dexMids(dex) {
353
+ const res = await this.client.info('allMids', { dex })
354
+ return res?.mids ?? res ?? {}
355
+ }
356
+
294
357
  /** Mid price for a perp coin: WS allMids first, REST fallback. */
295
358
  async midOf(coin) {
359
+ if (coin.includes(':')) {
360
+ const n = Number((await this.dexMids(coin.split(':')[0]))[coin])
361
+ if (Number.isFinite(n) && n > 0) return n
362
+ throw new Error(`无法获得 ${coin} 中间价`)
363
+ }
296
364
  this.touch(keys.allMids())
297
365
  const snap = this.client.snapshot(keys.allMids())
298
366
  if (snap?.data && Date.now() - snap.at < 5_000) {
@@ -358,9 +426,10 @@ export class HyperRemote extends TypertRemoteService {
358
426
  for (const [index, asset] of (meta?.universe ?? []).entries()) {
359
427
  const dex = asset.name.split(':')[0] || 'HIP-3'
360
428
  dexIndex.set(asset.name, { dex, index })
429
+ // USDC-collateral HIP-3 perps trade through the same order path (asset 100000 + dex·10000 + index).
361
430
  rows.push({ name: asset.name, label: asset.name, kind: 'hip3',
362
- venue: dex, tradable: false, ctx: contexts?.[index] ?? null,
363
- category: categories.get(asset.name) ?? null, maxLeverage: asset.maxLeverage })
431
+ venue: dex, tradable: meta?.collateralToken === 0 && !asset.isDelisted, ctx: contexts?.[index] ?? null,
432
+ category: categories.get(asset.name) ?? null, maxLeverage: asset.maxLeverage, szDecimals: asset.szDecimals })
364
433
  }
365
434
  }
366
435
  }
@@ -482,6 +551,20 @@ export class HyperRemote extends TypertRemoteService {
482
551
  return maLines
483
552
  }
484
553
 
554
+ /** Agent sets what the session chart shows (interval, indicator panes); the desk follows on `chart.view`. */
555
+ setSessionChartView(sessionId, { interval, show = [], hide = [] } = {}) {
556
+ this.requireTradingSession(sessionId)
557
+ const unknown = [...show, ...hide].filter((id) => !INDICATOR_IDS.includes(id))
558
+ if (unknown.length) throw new Error(`未知指标 ${unknown.join(',')},可选:${INDICATOR_IDS.join(', ')}`)
559
+ const current = this.store.getSessionWorkspace(sessionId)
560
+ const patch = { indicators: [...new Set([...(current.indicators ?? []).filter((id) => !hide.includes(id)), ...show])] }
561
+ if (interval != null) patch.interval = requireInterval(interval)
562
+ const next = this.store.updateSessionWorkspace(sessionId, patch)
563
+ if (this.desk?.sessionId === sessionId && patch.interval) this.desk.interval = patch.interval
564
+ this.store.recordEvent('chart.view', { sessionId, interval: next.interval, indicators: next.indicators })
565
+ return next
566
+ }
567
+
485
568
  /** Move, reshape, restyle or relabel one drawing; geometry is re-validated as a whole. */
486
569
  editChartMark(id, patch, sessionId) {
487
570
  this.requireTradingSession(sessionId)
@@ -492,7 +575,7 @@ export class HyperRemote extends TypertRemoteService {
492
575
  const fields = { ...drawingStyle({ ...patch, kind: current.kind }) }
493
576
  if (patch?.alert === true) fields.alertTriggeredAt = undefined
494
577
  // null clears a style back to its default (undo of a first restyle relies on this).
495
- for (const key of ['color', 'lineWidth', 'lineStyle']) if (key in patch && patch[key] === null) fields[key] = undefined
578
+ for (const key of ['color', 'lineWidth', 'lineStyle', 'rows', 'valueArea', 'levels']) if (key in patch && patch[key] === null) fields[key] = undefined
496
579
  if (geometryKeys.some((key) => key in patch)) Object.assign(fields, drawingGeometry(current.kind, merged))
497
580
  if ('label' in patch) {
498
581
  const label = String(patch.label ?? '').trim()
@@ -513,6 +596,15 @@ export class HyperRemote extends TypertRemoteService {
513
596
  * switches off, the time is recorded) and the desk gets a `chart.alert` event.
514
597
  * @param mids - coin → mid (defaults to the live allMids snapshot).
515
598
  */
599
+ /** Keep fresh mids for every HIP-3 dex that has an armed alert (polled with the alert check). */
600
+ async refreshAlertDexMids() {
601
+ const dexes = new Set((this.store.allChartMarks?.() ?? []).filter((mark) => mark.alert && String(mark.coin).includes(':')).map((mark) => mark.coin.split(':')[0]))
602
+ const client = this.client, network = this.cfg.network
603
+ const rows = await Promise.all([...dexes].map(async (dex) => [dex, { at: Date.now(), mids: await this.dexMids(dex) }]))
604
+ if (this.client !== client || this.cfg.network !== network) throw new Error('网络已切换,丢弃旧价格')
605
+ for (const [dex, value] of rows) this.dexMidCache.set(dex, value)
606
+ }
607
+
516
608
  checkDrawingAlerts(now = Date.now(), mids = null) {
517
609
  const watched = this.store.allChartMarks?.().filter((mark) => mark.alert && ALERT_KINDS.includes(mark.kind)) ?? []
518
610
  for (const id of [...this.alertSides.keys()]) if (!watched.some((mark) => mark.id === id)) this.alertSides.delete(id)
@@ -522,7 +614,8 @@ export class HyperRemote extends TypertRemoteService {
522
614
  const key = this.touch(keys.allMids())
523
615
  const snap = this.client.snapshot(key)
524
616
  if (!snap || now - snap.at > 10_000) return []
525
- book = snap.data?.mids ?? snap.data
617
+ book = { ...(snap.data?.mids ?? snap.data) }
618
+ for (const { at, mids: dexBook } of this.dexMidCache.values()) if (now - at <= 10_000) Object.assign(book, dexBook)
526
619
  }
527
620
  const fired = []
528
621
  for (const mark of watched) {
@@ -539,6 +632,8 @@ export class HyperRemote extends TypertRemoteService {
539
632
  price: mid, line, direction: side > 0 ? 'up' : 'down', at: now }
540
633
  this.store.recordEvent('chart.alert', event)
541
634
  fired.push({ ...event, mark: saved })
635
+ // The owning session's agent hears about it too (the host decides how, with its own guard rails).
636
+ for (const listener of this.alertListeners ?? []) { try { listener({ ...event, sessionId: mark.sessionId, mark: saved }) } catch { /* a listener must not stop the scan */ } }
542
637
  }
543
638
  return fired
544
639
  }
@@ -573,15 +668,22 @@ export class HyperRemote extends TypertRemoteService {
573
668
  patch.tapeTab = input.tapeTab
574
669
  }
575
670
  if ('bottomTab' in input) {
576
- if (!['positions', 'orders', 'account'].includes(input.bottomTab)) throw new Error('账户页签无效')
671
+ if (!['positions', 'orders', 'account', 'review'].includes(input.bottomTab)) throw new Error('账户页签无效')
577
672
  patch.bottomTab = input.bottomTab
578
673
  }
579
674
  if ('indicators' in input) {
580
- const allowed = ['volume', 'sma20', 'sma60', 'ema20', 'ema50', 'bollinger20', 'rsi14', 'macd']
675
+ const allowed = INDICATOR_IDS
581
676
  if (!Array.isArray(input.indicators) || input.indicators.length > allowed.length || input.indicators.some((id) => !allowed.includes(id))) throw new Error('图表指标无效')
582
677
  patch.indicators = [...new Set(input.indicators)]
583
678
  }
584
679
  if ('maLines' in input) patch.maLines = normalizeMaLines(input.maLines)
680
+ if ('indicatorSettings' in input) patch.indicatorSettings = normalizeIndicatorSettings(input.indicatorSettings)
681
+ if ('research' in input) patch.research = normalizeResearch(input.research)
682
+ if ('showDataWindow' in input) { if (typeof input.showDataWindow !== 'boolean') throw Error('数据窗口设置无效'); patch.showDataWindow = input.showDataWindow }
683
+ if ('chartType' in input) {
684
+ if (!['candles', 'bars', 'line', 'area', 'heikinAshi'].includes(input.chartType)) throw new Error('图表类型无效')
685
+ patch.chartType = input.chartType
686
+ }
585
687
  if ('priceScale' in input) {
586
688
  if (!['normal', 'log', 'percentage'].includes(input.priceScale)) throw new Error('价格刻度无效')
587
689
  patch.priceScale = input.priceScale
@@ -625,9 +727,30 @@ export class HyperRemote extends TypertRemoteService {
625
727
  return result
626
728
  }
627
729
 
730
+ async indicatorTemplates(request) {
731
+ const action = request?.action ?? 'list'
732
+ if (action === 'list') return { templates: this.store.indicatorTemplates() }
733
+ if (action === 'remove') { this.store.removeIndicatorTemplate(String(request?.id ?? '')); return { templates: this.store.indicatorTemplates() } }
734
+ if (action !== 'save') throw Error('指标模板操作无效')
735
+ const name = String(request?.name ?? '').trim()
736
+ if (!name || name.length > 60) throw Error('模板名称需要 1–60 个字符')
737
+ const indicators = request?.indicators
738
+ if (!Array.isArray(indicators) || indicators.length > INDICATOR_IDS.length || indicators.some((id) => !INDICATOR_IDS.includes(id))) throw Error('模板指标无效')
739
+ const id = request?.id
740
+ if (id != null && !this.store.indicatorTemplates().some((row) => row.id === id)) throw Error('指标模板不存在')
741
+ const template = this.store.saveIndicatorTemplate({ id, name, indicators: [...new Set(indicators)], indicatorSettings: normalizeIndicatorSettings(request?.indicatorSettings), maLines: normalizeMaLines(request?.maLines ?? []) })
742
+ return { template, templates: this.store.indicatorTemplates() }
743
+ }
744
+
745
+ async updatePaperCosts(request) {
746
+ if (this.activeTradeOps > 0) throw Error('交易操作仍在进行,请稍后修改模拟成本')
747
+ return { paperCosts: this.store.setPaperCosts(request?.costs) }
748
+ }
749
+
628
750
  async drawChartMark(request) {
629
751
  const sessionId = String(request?.sessionId ?? '')
630
- return this.addChartMark(request, sessionId, 'user')
752
+ // Undo re-creates a deleted agent drawing; keep its origin so the chart still labels it as the agent's.
753
+ return this.addChartMark(request, sessionId, request?.restoreSource === 'agent' ? 'agent' : 'user')
631
754
  }
632
755
 
633
756
  async eraseChartMark(request) {
@@ -651,6 +774,9 @@ export class HyperRemote extends TypertRemoteService {
651
774
  this.cfg.network = network
652
775
  this.client = new HyperliquidClient({ network, privateKey: this.cfg.privateKey })
653
776
  this.lastTouch.clear()
777
+ this.dexMidCache?.clear()
778
+ this.alertSides?.clear()
779
+ this.lastLiveAccount = null
654
780
  this.marketsCache = null
655
781
  this.marketsAt = 0
656
782
  this.catalogCache = null
@@ -676,6 +802,7 @@ export class HyperRemote extends TypertRemoteService {
676
802
  if (mode === 'live' && request?.acknowledgeLive !== true) throw new Error('切换实盘需要确认')
677
803
  const previous = this.cfg.tradingMode
678
804
  this.cfg.tradingMode = mode
805
+ this.lastLiveAccount = null
679
806
  this.accountModeCache = null
680
807
  this.accountModePending = null
681
808
  this.tradeCapacityCache.clear()
@@ -701,6 +828,136 @@ export class HyperRemote extends TypertRemoteService {
701
828
  return { riskLimits: { ...this.baseRiskSettings, usedTodayUsd: this.usedTodayUsd() } }
702
829
  }
703
830
 
831
+ /** Desk only: the dsh-web3 accounts the trader can sign with (no secrets, just id / label / address). */
832
+ async walletAccounts() {
833
+ const wallet = this.walletService()
834
+ if (!wallet) return { available: false, accounts: [] }
835
+ const accounts = typeof wallet.listAccounts === 'function' ? wallet.listAccounts() : []
836
+ return { available: true, accounts: accounts.filter((row) => row?.address).map((row) => ({ id: row.id, label: row.label ?? row.id, address: row.address })) }
837
+ }
838
+
839
+ /**
840
+ * Desk only (never an agent tool): change signer / web3 account / trading address at runtime. The values
841
+ * are validated, saved, and every cached account read is dropped, so the next status and account reads
842
+ * use the new wallet — no host restart. The private key stays in plugin config.
843
+ */
844
+ async updateWalletSettings(request) {
845
+ if (this.activeTradeOps > 0) throw new Error('交易操作仍在进行,请稍后再修改钱包设置')
846
+ const next = normalizeWalletSettings(request ?? {}, this.cfg)
847
+ if (next.signer === 'web3') {
848
+ const wallet = this.walletService()
849
+ if (!wallet) throw new Error('dsh-web3 未安装或未启用:请先在 DSH 中启用 Web3 钱包插件')
850
+ const accounts = typeof wallet.listAccounts === 'function' ? wallet.listAccounts() : []
851
+ if (!accounts.some((row) => row?.id === next.web3Account || row?.label === next.web3Account)) {
852
+ throw new Error(`dsh-web3 中找不到账户「${next.web3Account}」。如果它刚在另一个进程里创建,当前宿主的钱包列表还没刷新,需要重启 DSH`)
853
+ }
854
+ }
855
+ if (next.signer === 'local' && !this.cfg.privateKey) throw new Error('本地签名需要在插件配置里填写私钥')
856
+ if (next.signer === 'web3' && !next.tradingAddress) throw new Error('使用 dsh-web3 签名时需要填写 Hyperliquid 主账户地址')
857
+ Object.assign(this.cfg, next)
858
+ this.walletEpoch += 1
859
+ this.store.saveWalletSettings(next)
860
+ this.addressCache = null; this.walletAddressCache = null
861
+ this.accountModeCache = null; this.accountModePending = null
862
+ this.tradeCapacityCache.clear(); this.tradeCapacityPending.clear()
863
+ this.lastLiveAccount = null
864
+ this.guard.plans.clear()
865
+ this.store.recordEvent('wallet.changed', { signer: next.signer, web3Account: next.web3Account, tradingAddress: next.tradingAddress })
866
+ return { signer: next.signer, web3Account: next.web3Account, tradingAddress: next.tradingAddress, address: await this.resolveAddress() }
867
+ }
868
+
869
+ /** 全托管: the agent may act without per-action approval (the dsh-web3 wallet policy still applies). */
870
+ isManaged() { return this.cfg.custody ? this.cfg.custody === 'managed' : this.cfg.autonomousLive === true }
871
+
872
+ /** Desk only (never an agent tool): the trader picks 全托管 / 半托管. */
873
+ async setCustody(request) {
874
+ if (this.activeTradeOps > 0) throw new Error('交易操作仍在进行,暂不能切换托管模式')
875
+ const mode = request?.mode
876
+ if (mode !== 'managed' && mode !== 'assisted') throw new Error('托管模式必须为 managed(全托管)或 assisted(半托管)')
877
+ if (mode === 'managed' && this.cfg.tradingMode === 'live' && request?.acknowledge !== true) throw new Error('开启全托管实盘需要确认')
878
+ this.cfg.custody = this.store.saveCustody(mode)
879
+ this.guard.plans.clear()
880
+ this.store.recordEvent('custody.changed', { mode })
881
+ return { custody: mode }
882
+ }
883
+
884
+ /**
885
+ * Agent edit of the trading limits — 全托管 only; in 半托管 the trader changes them on the desk.
886
+ * `addCoins` / `removeCoins` adjust the allow-list; numeric limits replace the current value.
887
+ */
888
+ agentUpdateRiskSettings(request) {
889
+ if (!this.isManaged()) throw new Error('当前为半托管:交易限额与允许币对只能由你在交易台 设置 → 交易限额与签名规则 中修改;如需让 agent 自行调整,请在 设置 → 托管模式 切换为全托管')
890
+ const current = { allowedCoins: [...this.cfg.allowedCoins], maxOrderUsd: this.cfg.maxOrderUsd, maxDailyUsd: this.cfg.maxDailyUsd, maxPositionUsd: this.cfg.maxPositionUsd, maxLeverage: this.cfg.maxLeverage }
891
+ const add = (request?.addCoins ?? []).map((coin) => canonicalMarketCoin(coin))
892
+ const drop = new Set((request?.removeCoins ?? []).map((coin) => canonicalMarketCoin(coin)))
893
+ const next = { ...current, allowedCoins: [...new Set([...current.allowedCoins.filter((coin) => !drop.has(coin)), ...add])] }
894
+ for (const key of ['maxOrderUsd', 'maxDailyUsd', 'maxPositionUsd', 'maxLeverage']) if (request?.[key] != null) next[key] = request[key]
895
+ if (this.activeTradeOps > 0) throw new Error('交易操作仍在进行,暂不能修改限额')
896
+ const limits = normalizeRiskSettings(next)
897
+ this.store.saveRiskSettings(limits)
898
+ this.applyRiskSettings(limits, true)
899
+ this.store.recordEvent('risk.changed', { by: 'agent', before: current, after: limits })
900
+ return { before: current, after: limits }
901
+ }
902
+
903
+ /**
904
+ * Move USDC between the core perp account ('') and a HIP-3 dex (e.g. 'xyz') with a user-signed
905
+ * `sendAsset` (main wallet). 半托管 asks the trader every time; 全托管 goes straight to the wallet.
906
+ */
907
+ async transferCollateral(request, exec) {
908
+ if (this.cfg.tradingMode !== 'live') throw new Error('paper 模式没有交易所资金,无需划转')
909
+ const transferEpoch = this.walletEpoch
910
+ if (!exec?.agent) throw new Error('资金划转仅允许从 DSH agent 工具调用')
911
+ exec.signal?.throwIfAborted()
912
+ const dex = String(request?.dex ?? '').trim()
913
+ const toDex = request?.direction !== 'out'
914
+ if (!dex || dex.includes(':')) throw new Error('dex 需要 HIP-3 交易所名,如 xyz')
915
+ const dexs = await this.perpDexList()
916
+ if (!dexs.some((row) => row?.name === dex)) throw new Error(`未知的 HIP-3 交易所 ${dex}`)
917
+ const meta = await this.client.info('meta', { dex })
918
+ if (meta?.collateralToken !== 0) throw new Error(`${dex} 不使用 USDC 保证金,暂不支持划转`)
919
+ const amount = Number(request?.amount)
920
+ if (!Number.isFinite(amount) || amount <= 0) throw new Error('amount 必须为正数(USDC)')
921
+ if (Number(amount.toFixed(6)) !== amount) throw new Error('USDC 划转金额最多 6 位小数')
922
+ if (amount > this.cfg.maxPositionUsd) throw new Error(`单次划转不超过仓位上限 ${this.cfg.maxPositionUsd} USD`)
923
+ const address = await this.resolveAddress()
924
+ if (!address) throw new Error('live 划转需要钱包地址')
925
+ const signerAddress = this.cfg.signer === 'web3' ? await this.web3Address() : await addressFromPrivateKey(this.cfg.privateKey)
926
+ if (signerAddress.toLowerCase() !== address.toLowerCase()) throw new Error('保证金划转需要交易主钱包签名;当前签名钱包与 tradingAddress 不一致,API wallet 不能用于此划转')
927
+ const source = toDex ? '' : dex
928
+ const sourceState = source ? await this.client.info('clearinghouseState', { user: address, dex: source }) : await this.client.clearinghouseState(address)
929
+ const withdrawable = Number(sourceState?.withdrawable)
930
+ if (!Number.isFinite(withdrawable)) throw new Error('无法确认可划转余额')
931
+ if (amount > withdrawable + 1e-9) throw new Error(`${source || '主永续账户'} 可划转 ${withdrawable.toFixed(2)} USDC,不足 ${amount}`)
932
+ const spotMeta = await this.client.info('spotMeta')
933
+ const usdc = (spotMeta?.tokens ?? []).find((token) => token.index === 0 && token.name === 'USDC')
934
+ if (!usdc?.tokenId) throw new Error('无法读取 USDC 代币信息')
935
+ const what = `${amount} USDC:${toDex ? `主永续账户 → ${dex}` : `${dex} → 主永续账户`}`
936
+ if (!this.isManaged()) await this.authorizeToolAction(`Hyperliquid ${this.cfg.network} 划转 ${what}?`, exec, `Hyperliquid ${this.cfg.network}: transfer ${amount} USDC ${toDex ? `core perps → ${dex}` : `${dex} → core perps`}?`)
937
+ if (transferEpoch !== this.walletEpoch) throw new Error('钱包已切换,划转已取消')
938
+ const nonce = this.client.nextNonce()
939
+ const { action, typed } = buildSendAsset({ destination: address, sourceDex: source, destinationDex: toDex ? dex : '', token: `USDC:${usdc.tokenId}`,
940
+ amount: String(Number(amount.toFixed(6))), nonce, isMainnet: this.cfg.network === 'mainnet' })
941
+ let signature
942
+ if (this.cfg.signer === 'web3') {
943
+ const wallet = this.walletService()
944
+ if (!wallet) throw new Error('signer=web3 但 dsh-web3 未安装')
945
+ const result = await wallet.signTypedData({ account: this.cfg.web3Account, ...typed, purpose: `Hyperliquid ${this.cfg.network} 资金划转:${what}` },
946
+ this.approvalGateway(), { toolName: 'dsh-hyperliquid', agent: exec?.agent, callId: exec?.callId, signal: exec?.signal })
947
+ if (result?.status !== 'signed' || !result.signature) throw new Error(`dsh-web3 签名未完成(status=${result?.status})`)
948
+ if (result.address?.toLowerCase() !== signerAddress.toLowerCase()) throw new Error('dsh-web3 返回的签名地址与配置账户不一致')
949
+ signature = splitSignature(result.signature)
950
+ } else {
951
+ if (!this.cfg.privateKey) throw new Error('live 划转需要钱包私钥')
952
+ signature = await signTypedDataLocal(this.cfg.privateKey, typed)
953
+ }
954
+ exec.signal?.throwIfAborted()
955
+ const res = await this.client.exchangeUserSigned(action, signature, nonce)
956
+ assertExchangeAccepted(res)
957
+ this.store.recordEvent('live.transfer', { dex, amount, toDex })
958
+ return { ok: true, dex, amount, direction: toDex ? 'in' : 'out', response: res }
959
+ }
960
+
704
961
  applyRiskSettings(limits, override) {
705
962
  Object.assign(this.cfg, limits)
706
963
  Object.assign(this.guard.limits, limits)
@@ -722,10 +979,14 @@ export class HyperRemote extends TypertRemoteService {
722
979
  const address = await this.resolveAddress()
723
980
  const wallet = this.cfg.signer === 'web3' ? this.walletService() : null
724
981
  return {
982
+ paperCosts: this.store.paperCosts?.() ?? { feeBps: 4.5, slippageBps: 5 },
725
983
  network: this.cfg.network,
726
984
  tradingMode: this.cfg.tradingMode,
727
985
  signer: this.cfg.signer,
728
- autonomousLive: this.cfg.autonomousLive,
986
+ web3Account: this.cfg.web3Account,
987
+ tradingAddress: this.cfg.tradingAddress,
988
+ autonomousLive: this.isManaged(),
989
+ custody: this.cfg.custody,
729
990
  riskSettingsSource: this.riskSettingsOverride ? 'desk' : 'plugin',
730
991
  riskLimits: { allowedCoins: this.cfg.allowedCoins, maxOrderUsd: this.cfg.maxOrderUsd, maxDailyUsd: this.cfg.maxDailyUsd, maxPositionUsd: this.cfg.maxPositionUsd, maxLeverage: this.cfg.maxLeverage,
731
992
  // Day budget of the ledger that applies to the current mode (paper and live are separate).
@@ -826,33 +1087,35 @@ export class HyperRemote extends TypertRemoteService {
826
1087
  async account(request) {
827
1088
  if (this.cfg.tradingMode === 'paper') {
828
1089
  const paper = this.store.paperAccount()
829
- const mids = await safeMids(this)
1090
+ const mids = await safeMids(this, Object.keys(paper.positions ?? {}))
830
1091
  const risk = paperRiskState(paper, mids)
831
1092
  return { mode: 'paper', usd: paper.usd, equity: risk.equity, uPnl: risk.uPnl,
832
1093
  marginUsed: risk.marksConfirmed ? risk.marginUsed : null,
833
1094
  availableMargin: risk.marksConfirmed ? Math.max(0, risk.equity - risk.marginUsed) : null,
834
1095
  marksConfirmed: risk.marksConfirmed, positions: risk.positions, fills: paper.fills.slice(0, 50),
835
- simulation: '固定滑点;未模拟盘口深度、手续费、资金费或强平' }
1096
+ simulation: '已模拟配置的滑点与手续费;未模拟盘口深度、资金费或强平' }
836
1097
  }
1098
+ const client = this.client, network = this.cfg.network
1099
+ const walletEpoch = this.walletEpoch
837
1100
  const address = await this.resolveAddress()
838
- if (!address) throw new Error('live 模式需要钱包(config.privateKey 或 signer=web3)')
1101
+ if (!address) throw new Error('live 模式需要钱包:在交易台 设置 → 交易限额与签名规则 → 签名与托管 中连接')
839
1102
  const accountMode = await this.accountMode(address)
840
1103
  const sharedBalance = accountMode === 'unifiedAccount' || accountMode === 'portfolioMargin'
841
1104
  const activeCoin = canonicalMarketCoin(request?.coin ?? this.desk?.coin)
842
1105
  const accountDex = activeCoin.includes(':') ? activeCoin.split(':')[0] : ''
843
1106
  const stateKey = this.touch(keys.allDexsClearinghouseState(address))
844
- const wsState = this.client.snapshot(stateKey)
1107
+ const wsState = client.snapshot(stateKey)
845
1108
  const liveStates = wsState && Date.now() - wsState.at < 10_000 &&
846
1109
  wsState.data?.user?.toLowerCase() === address.toLowerCase() && Array.isArray(wsState.data?.clearinghouseStates)
847
1110
  ? new Map(wsState.data.clearinghouseStates.filter((entry) => Array.isArray(entry) && typeof entry[0] === 'string' && entry[1] && Array.isArray(entry[1].assetPositions)))
848
1111
  : null
849
1112
  const [state, open, markets, spot] = await Promise.all([
850
1113
  liveStates?.get(accountDex) ?? (accountDex
851
- ? this.client.info('clearinghouseState', { user: address, dex: accountDex })
852
- : this.client.clearinghouseState(address)),
853
- this.client.frontendOpenOrders(address),
1114
+ ? client.info('clearinghouseState', { user: address, dex: accountDex })
1115
+ : client.clearinghouseState(address)),
1116
+ client.frontendOpenOrders(address),
854
1117
  this.markets().catch(() => null),
855
- sharedBalance ? this.client.spotClearinghouseState(address) : Promise.resolve(null),
1118
+ sharedBalance ? client.spotClearinghouseState(address) : Promise.resolve(null),
856
1119
  ])
857
1120
  if (!state || !Array.isArray(state.assetPositions) || (!sharedBalance && (!state.marginSummary || !Number.isFinite(Number(state.marginSummary.accountValue))))) {
858
1121
  throw new Error('交易所永续账户响应无效;账户状态未确认')
@@ -885,7 +1148,7 @@ export class HyperRemote extends TypertRemoteService {
885
1148
  maxLeverage: row.position?.maxLeverage ?? null,
886
1149
  leverage: row.position?.leverage ?? null,
887
1150
  })))
888
- return {
1151
+ const liveAccount = {
889
1152
  mode: 'live',
890
1153
  address,
891
1154
  accountMode,
@@ -902,6 +1165,12 @@ export class HyperRemote extends TypertRemoteService {
902
1165
  positions: assetPositions,
903
1166
  openOrders: open,
904
1167
  }
1168
+ // The system prompt reads this (synchronously) to warn about unprotected positions.
1169
+ if (this.client !== client || this.cfg.network !== network || this.cfg.tradingMode !== 'live') throw new Error('账户环境已切换,请重新读取')
1170
+ // A response that started under the previous wallet must not land in the new wallet's view.
1171
+ if (walletEpoch !== this.walletEpoch) throw new Error('钱包已切换,账户数据已过期')
1172
+ this.lastLiveAccount = { at: Date.now(), network, value: liveAccount }
1173
+ return liveAccount
905
1174
  }
906
1175
 
907
1176
  /** Open orders (live mode; paper has none resting). */
@@ -926,25 +1195,45 @@ export class HyperRemote extends TypertRemoteService {
926
1195
 
927
1196
  /** Explicitly loaded history: these weighted API calls must not join the 3s account poll. */
928
1197
  async tradeHistory(request) {
929
- if (this.cfg.tradingMode === 'paper') {
1198
+ const client = this.client, mode = this.cfg.tradingMode, network = this.cfg.network
1199
+ if (request?.days != null && ![7, 30, 90].includes(request.days)) throw Error('复盘支持最近 7/30/90 天')
1200
+ const end = Date.now(), requestedStart = request?.days != null ? end - request.days * 86_400_000 : request?.startTime == null ? null : Number(request.startTime)
1201
+ if (requestedStart != null && (!Number.isFinite(requestedStart) || requestedStart < end - 90 * 86_400_000 || requestedStart >= end)) throw Error('复盘时间范围必须在最近 90 天内')
1202
+ const start = requestedStart ?? Math.max(0, end - 30 * 86_400_000)
1203
+ let result
1204
+ if (mode === 'paper') {
930
1205
  const paper = this.store.paperAccount()
931
- return { mode: 'paper', orders: paper.orders.slice(0, 200), fills: paper.fills.slice(0, 200) }
1206
+ result = { mode: 'paper', orders: paper.orders.slice(0, 5000), fills: paper.fills.slice(0, 5000), paperCosts: this.store.paperCosts(), funding: [],
1207
+ coverage: { fundingStatus: 'not-simulated', fillsLimited: paper.fills.length >= 200, ordersLimited: paper.orders.length >= 200 } }
1208
+ } else {
1209
+ const address = await this.resolveAddress()
1210
+ if (!address) throw Error('未配置交易主账户地址')
1211
+ const [orders, fills] = await Promise.all([client.historicalOrders(address), client.userFills(address)])
1212
+ if (!Array.isArray(orders) || !Array.isArray(fills)) throw Error('交易所历史数据响应无效')
1213
+ const decisions = this.store.liveDecisions ?? {}
1214
+ const rows = orders.slice(0, 2000).map((entry) => {
1215
+ const order = entry.order ?? entry, local = this.store.liveOrders?.[order.cloid], decision = decisions[local?.decisionId]
1216
+ return { ...order, status: entry.status ?? local?.status ?? 'unknown', statusTimestamp: entry.statusTimestamp,
1217
+ referencePx: decision?.preview?.mid ?? null,
1218
+ reason: local?.thesis ?? decision?.thesis ?? null, sessionId: local?.sessionId ?? decision?.sessionId ?? null }
1219
+ })
1220
+ result = { mode: 'live', orders: rows, fills: fills.slice(0, 2000), funding: [],
1221
+ coverage: { fundingStatus: 'not-requested', fillsLimited: fills.length >= 2000, ordersLimited: orders.length >= 2000 },
1222
+ localOrders: Object.entries(this.store.liveOrders ?? {}).map(([cloid, row]) => ({ cloid, coin: row.coin, status: row.status, at: row.updatedAt ?? row.at, sessionId: row.sessionId })) }
1223
+ if (request?.includeFunding) {
1224
+ try {
1225
+ const funding = await fundingWindow(client, address, start, end)
1226
+ result.funding = funding.rows
1227
+ result.coverage.fundingStatus = funding.status
1228
+ } catch (error) { result.coverage.fundingStatus = 'error'; result.coverage.fundingError = String(error?.message ?? error) }
1229
+ }
932
1230
  }
933
- const address = await this.resolveAddress()
934
- if (!address) throw new Error('未配置交易主账户地址')
935
- const [orders, fills] = await Promise.all([
936
- this.client.historicalOrders(address), this.client.userFills(address),
937
- ])
938
- if (!Array.isArray(orders) || !Array.isArray(fills)) throw new Error('交易所历史数据响应无效')
939
- const localByCloid = this.store.liveOrders
940
- const decisions = this.store.liveDecisions
941
- return { mode: 'live', orders: orders.slice(0, 2000).map((entry) => {
942
- const order = entry.order ?? entry
943
- const local = localByCloid[order.cloid]
944
- return { ...order, status: entry.status ?? local?.status ?? 'unknown', statusTimestamp: entry.statusTimestamp,
945
- reason: local?.thesis ?? (local?.decisionId ? decisions[local.decisionId]?.thesis ?? null : null),
946
- sessionId: local?.sessionId ?? (local?.decisionId ? decisions[local.decisionId]?.sessionId ?? null : null) }
947
- }), fills: fills.slice(0, 2000) }
1231
+ if (requestedStart != null) {
1232
+ result.fills = result.fills.filter((row) => Number(row.time) >= start && Number(row.time) <= end)
1233
+ const ids = new Set(result.fills.filter((row) => row.oid != null).map((row) => `${row.coin}:${row.oid}`))
1234
+ result.orders = result.orders.filter((row) => Number(row.timestamp ?? row.time) >= start || ids.has(`${row.coin}:${row.oid}`))
1235
+ }
1236
+ return { ...result, network, observedAt: Date.now(), coverage: { ...result.coverage, startTime: start, endTime: end, bounded: true } }
948
1237
  }
949
1238
 
950
1239
  /** Hyperliquid portfolio samples are display data, not an accounting ledger. */
@@ -953,7 +1242,10 @@ export class HyperRemote extends TypertRemoteService {
953
1242
  const account = await this.account({})
954
1243
  const fills = this.store.paperAccount().fills
955
1244
  return { mode: 'paper', equity: account.equity, unrealizedPnl: account.uPnl,
956
- realizedPnl: fills.reduce((sum, fill) => sum + Number(fill.realized ?? 0), 0), series: {} }
1245
+ realizedPnl: fills.reduce((sum, fill) => sum + Number(fill.realized ?? 0), 0),
1246
+ fees: fills.reduce((sum, fill) => sum + Number(fill.fee ?? 0), 0),
1247
+ netRealizedPnl: fills.some((fill) => fill.fee == null) ? null : fills.reduce((sum, fill) => sum + Number(fill.realized ?? 0) - Number(fill.fee), 0),
1248
+ retainedFills: fills.length, series: {} }
957
1249
  }
958
1250
  const address = await this.resolveAddress()
959
1251
  if (!address) throw new Error('未配置交易主账户地址')
@@ -1045,7 +1337,11 @@ export class HyperRemote extends TypertRemoteService {
1045
1337
  const thesis = String(request?.thesis ?? '').trim()
1046
1338
  if (thesis.length < 10 || thesis.length > 1000) throw new Error('交易决策需要 10-1000 字的依据')
1047
1339
  const order = orderOf(request)
1048
- const preview = await this.checkLiveOrder(order)
1340
+ const epoch = this.walletEpoch
1341
+ const checked = await this.checkLiveOrder(order)
1342
+ // A wallet switch while the pre-check was in flight voids it: the decision belongs to the old wallet.
1343
+ if (epoch !== this.walletEpoch) throw new Error('钱包已切换,请重新预检')
1344
+ const preview = { ...checked, walletEpoch: epoch }
1049
1345
  const decision = this.guard.create(order, sessionId, preview)
1050
1346
  this.store.recordLiveDecision(decision.decisionId, { sessionId, order, thesis, preview, expiresAt: decision.expiresAt })
1051
1347
  return decision
@@ -1060,19 +1356,37 @@ export class HyperRemote extends TypertRemoteService {
1060
1356
  const address = await this.resolveAddress()
1061
1357
  if (!address) throw new Error('live 交易需要有效的 tradingAddress 或本地钱包')
1062
1358
  if (this.cfg.signer === 'web3') await this.web3Address()
1063
- const [market, mids, state, accountMode] = await Promise.all([
1064
- this.markets(), client.allMids(), client.clearinghouseState(address), this.accountMode(address, true),
1359
+ const asset = await this.tradeAsset(order.coin)
1360
+ // A HIP-3 dex has its own clearinghouse: margin, positions and withdrawable live there.
1361
+ const [mids, state, accountMode] = await Promise.all([
1362
+ asset.dex ? this.dexMids(asset.dex) : client.allMids(),
1363
+ asset.dex ? client.info('clearinghouseState', { user: address, dex: asset.dex }) : client.clearinghouseState(address),
1364
+ this.accountMode(address, true),
1065
1365
  ])
1066
1366
  if (!Array.isArray(state?.assetPositions)) throw new Error('交易所持仓状态未确认,拒绝 live 下单')
1067
1367
  const sharedBalance = accountMode === 'unifiedAccount' || accountMode === 'portfolioMargin'
1068
- const asset = market.universe.find((u) => u.name === order.coin)
1069
- if (!asset) throw new Error(`未知合约 ${order.coin}`)
1368
+ let positionStates = [state]
1369
+ if (sharedBalance) {
1370
+ // Shared USDC backs every perp venue. Read fresh positions rather than
1371
+ // the UI's partial/cached snapshot; Standard only risks its own dex equity.
1372
+ const [dexs, metas] = await Promise.all([client.info('perpDexs'), accountMode === 'unifiedAccount' ? client.info('allPerpMetas') : Promise.resolve(null)])
1373
+ if (!Array.isArray(dexs) || dexs.some((dex) => dex !== null && (typeof dex?.name !== 'string' || !dex.name))) throw new Error('交易所持仓状态未确认:无法读取交易所列表')
1374
+ if (metas && (!Array.isArray(metas) || metas.length !== dexs.length || metas.some((meta) => !Number.isInteger(meta?.collateralToken)))) throw new Error('交易所持仓状态未确认:无法核对保证金资产')
1375
+ if (accountMode === 'unifiedAccount' && !metas) throw new Error('交易所持仓状态未确认:无法核对保证金资产')
1376
+ // Unified pools are per collateral token; USDH/USDT dexes do not spend
1377
+ // USDC. Portfolio mode remains conservative across its eligible assets.
1378
+ const pooledDexs = dexs.filter((dex, index) => dex && (!metas || metas[index].collateralToken === 0))
1379
+ const names = [...new Set(['', asset.dex ?? '', ...pooledDexs.map((dex) => dex.name)])]
1380
+ positionStates = await Promise.all(names.map((dex) => dex === (asset.dex ?? '') ? state
1381
+ : dex ? client.info('clearinghouseState', { user: address, dex }) : client.clearinghouseState(address)))
1382
+ if (positionStates.some((value) => !Array.isArray(value?.assetPositions))) throw new Error('交易所持仓状态未确认,拒绝 live 下单')
1383
+ }
1070
1384
  const mid = Number(mids?.mids?.[order.coin] ?? mids?.[order.coin])
1071
1385
  if (!Number.isFinite(mid) || mid <= 0) throw new Error('无法获取实时价格,拒绝 live 下单')
1072
1386
  validateProtection(order, mid)
1073
1387
  if (Number(wireSz(order.sz, asset.szDecimals)) !== order.sz) throw new Error(`数量不符合 ${order.coin} 的精度 ${asset.szDecimals}`)
1074
1388
  const row = (state?.assetPositions ?? []).find((p) => p.position?.coin === order.coin)?.position
1075
- const otherExposureUsd = (state?.assetPositions ?? []).reduce((sum, p) => {
1389
+ const otherExposureUsd = positionStates.flatMap((value) => value.assetPositions).reduce((sum, p) => {
1076
1390
  if (p.position?.coin === order.coin) return sum
1077
1391
  const value = Number(p.position?.positionValue)
1078
1392
  if (!Number.isFinite(value)) throw new Error('无法读取其他仓位敞口')
@@ -1092,10 +1406,13 @@ export class HyperRemote extends TypertRemoteService {
1092
1406
  const sideCapacity = Math.min(...capacity.maxTradeSzs.map(Number))
1093
1407
  if (!order.reduceOnly && order.sz > sideCapacity + 1e-10) throw new Error('订单数量超过交易所当前可交易额度')
1094
1408
  }
1409
+ if (asset.dex && !sharedBalance && !order.reduceOnly && !(accountValue > 0)) {
1410
+ throw new Error(`${asset.dex} 是独立保证金的 HIP-3 交易所,账户里没有 USDC。请在 Hyperliquid 网页把 USDC 从主永续账户划转到 ${asset.dex},或开启统一账户(Unified Account),之后 HIP-3 下单会自动调用主账户余额`)
1411
+ }
1095
1412
  const riskPrice = order.type === 'limit' ? Math.max(mid * 1.05, order.px) : mid * 1.05
1096
1413
  const checked = this.guard.validate({ ...order, price: riskPrice, positionSize, otherExposureUsd, accountValue, leverage })
1097
1414
  if (client !== this.client || network !== this.cfg.network) throw new Error('网络已切换,请重新预检交易')
1098
- return { coin: order.coin, side: order.isBuy ? 'buy' : 'sell', mid, riskPrice, accountValue, accountMode, positionSize, otherExposureUsd, leverage, ...checked, network, address }
1415
+ return { coin: order.coin, dex: asset.dex, side: order.isBuy ? 'buy' : 'sell', mid, riskPrice, accountValue, accountMode, positionSize, otherExposureUsd, leverage, ...checked, network, address }
1099
1416
  }
1100
1417
 
1101
1418
  async executeToolOrder(request, exec, sessionId) {
@@ -1132,8 +1449,7 @@ export class HyperRemote extends TypertRemoteService {
1132
1449
  const reduceOnly = Boolean(request?.reduceOnly)
1133
1450
  if (this.cfg.tradingMode === 'paper') {
1134
1451
  if (request?.takeProfitPx != null || request?.stopLossPx != null) throw new Error('paper 暂不模拟止盈止损触发单;请移除保护价格或切换 testnet live')
1135
- const { universe } = await this.markets()
1136
- if (!universe.some((row) => row.name === coin)) throw new Error(`当前仅支持核心永续合约模拟交易:${coin}`)
1452
+ await this.tradeAsset(coin)
1137
1453
  const mid = await this.midOf(coin)
1138
1454
  const pos = this.store.paper.positions[coin]?.size ?? 0
1139
1455
  if (reduceOnly && (pos === 0 || Math.sign(isBuy ? sz : -sz) === Math.sign(pos) || sz > Math.abs(pos) + 1e-10)) throw new Error('只减仓订单会增加或翻转仓位')
@@ -1142,30 +1458,32 @@ export class HyperRemote extends TypertRemoteService {
1142
1458
  if (!Number.isFinite(limit) || limit <= 0) throw new Error('限价单需要有效 px')
1143
1459
  if ((isBuy && limit < mid) || (!isBuy && limit > mid)) throw new Error('paper 当前只支持可立即成交的限价单')
1144
1460
  }
1145
- const slip = Number(request?.slippagePct)
1146
- const pct = Number.isFinite(slip) ? Math.min(Math.max(slip, 0), 10) / 100 : 0.0005
1461
+ const costs = this.store.paperCosts()
1462
+ const slip = request?.slippagePct == null ? null : Number(request.slippagePct)
1463
+ const pct = slip != null && Number.isFinite(slip) ? Math.min(Math.max(slip, 0), 10) / 100 : costs.slippageBps / 10_000
1147
1464
  const px = mid * (isBuy ? 1 + pct : 1 - pct)
1148
1465
  if (request?.type === 'limit' && (isBuy ? px > Number(request.px) : px < Number(request.px))) throw new Error('paper 模拟滑点后无法满足限价')
1149
1466
  const paper = this.store.paperAccount()
1150
- const risk = paperRiskState(paper, { ...await safeMids(this), [coin]: px }, true)
1467
+ const risk = paperRiskState(paper, { ...await safeMids(this, Object.keys(paper.positions)), [coin]: mid }, true)
1151
1468
  const positionSize = Number(paper.positions[coin]?.size ?? 0)
1152
1469
  const leverage = Number(paper.positions[coin]?.leverage ?? paper.leverageByCoin?.[coin] ?? 1)
1153
1470
  const otherExposureUsd = risk.positions.reduce((sum, p) => sum + (p.coin === coin ? 0 : Math.abs(p.size) * p.markPx), 0)
1154
1471
  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' })
1155
1472
  const checked = guard.validate({ coin, isBuy, sz, price: px, positionSize, otherExposureUsd, accountValue: risk.equity, leverage, reduceOnly })
1156
- const currentMargin = Math.abs(positionSize) * px / leverage
1157
- const projectedMargin = risk.marginUsed - currentMargin + Math.abs(checked.nextSize) * px / leverage
1158
- if (!checked.riskReducing && projectedMargin > risk.equity + 1e-8) throw new Error('模拟账户可用保证金不足')
1473
+ const currentMargin = Math.abs(positionSize) * mid / leverage
1474
+ const projectedMargin = risk.marginUsed - currentMargin + Math.abs(checked.nextSize) * mid / leverage
1475
+ const projectedEquity = risk.equity - checked.notionalUsd * costs.feeBps / 10_000 - sz * (isBuy ? px - mid : mid - px)
1476
+ if (!checked.riskReducing && projectedMargin > projectedEquity + 1e-8) throw new Error('模拟账户可用保证金不足')
1159
1477
  const budgetTicket = guard.reserve(checked.notionalUsd, { riskReducing: checked.riskReducing })
1160
1478
  let fill
1161
1479
  const sessionId = request?.[TOOL_CALL]?.sessionId ?? null
1162
- try { fill = this.store.paperFill({ coin, isBuy, sz, px, reason: request?.reason ?? 'chat', sessionId }) }
1480
+ try { fill = this.store.paperFill({ coin, isBuy, sz, px, reason: request?.reason ?? 'chat', sessionId, feeBps: costs.feeBps, referencePx: mid, slippageBps: pct * 10_000 }) }
1163
1481
  catch (err) { guard.release(budgetTicket); throw err }
1164
- this.store.paperOrderRecord({ coin, isBuy, sz, px: request?.type === 'limit' ? Number(request.px) : px,
1482
+ this.store.paperOrderRecord({ oid: fill.oid, referencePx: mid, coin, isBuy, sz, px: request?.type === 'limit' ? Number(request.px) : px,
1165
1483
  filledPx: px, type: request?.type === 'limit' ? 'limit' : 'market', reduceOnly,
1166
1484
  reason: request?.reason ?? '', sessionId })
1167
- return { mode: 'paper', fill, marginUsed: projectedMargin, availableMargin: Math.max(0, risk.equity - projectedMargin),
1168
- simulation: '固定滑点;未模拟盘口深度、手续费、资金费或强平' }
1485
+ return { mode: 'paper', fill, marginUsed: projectedMargin, availableMargin: Math.max(0, projectedEquity - projectedMargin),
1486
+ simulation: '已模拟配置的滑点与手续费;未模拟盘口深度、资金费或强平' }
1169
1487
  }
1170
1488
  // live
1171
1489
  const trusted = request?.[TOOL_CALL]
@@ -1173,16 +1491,17 @@ export class HyperRemote extends TypertRemoteService {
1173
1491
  const orderSpec = orderOf(request)
1174
1492
  const preview = this.guard.consume(request?.decisionId, orderSpec, trusted.sessionId)
1175
1493
  if (preview.network !== this.cfg.network) throw new Error('交易决策的网络与当前网络不一致,请重新预检')
1494
+ if (preview.walletEpoch !== this.walletEpoch) throw new Error('交易决策来自切换前的钱包,请重新预检')
1176
1495
  const fresh = await this.checkLiveOrder(orderSpec)
1496
+ if (String(fresh.address ?? '').toLowerCase() !== String(preview.address ?? '').toLowerCase() || preview.walletEpoch !== this.walletEpoch) {
1497
+ throw new Error('交易账户与预检时不一致,请重新预检')
1498
+ }
1177
1499
  if (Math.abs(fresh.mid / preview.mid - 1) > 0.01) throw new Error('价格已偏离预检超过 1%,请重新决策')
1178
- if (!this.cfg.autonomousLive) {
1500
+ if (!this.isManaged()) {
1179
1501
  await this.authorizeToolAction(`Hyperliquid ${this.cfg.network} ${coin} ${isBuy ? '买入' : '卖出'} ${sz},预估名义价值 ${fresh.notionalUsd.toFixed(2)} USD。是否允许本次交易?`, trusted.exec,
1180
1502
  `Hyperliquid ${this.cfg.network}: ${isBuy ? 'buy' : 'sell'} ${sz} ${coin}, estimated notional ${fresh.notionalUsd.toFixed(2)} USD. Allow this trade?`)
1181
1503
  }
1182
- const { universe } = await this.markets()
1183
- const idx = universe.findIndex((u) => u.name === coin)
1184
- if (idx < 0) throw new Error(`未知合约 ${coin}`)
1185
- const szDecimals = universe[idx].szDecimals
1504
+ const { asset: idx, szDecimals } = await this.tradeAsset(coin)
1186
1505
  const tif = request?.type === 'limit' ? (request?.tif === 'Alo' ? 'Alo' : 'Gtc') : 'Ioc'
1187
1506
  const mid = await this.midOf(coin)
1188
1507
  let pxNum
@@ -1256,10 +1575,8 @@ export class HyperRemote extends TypertRemoteService {
1256
1575
  if (this.cfg.tradingMode === 'paper') throw new Error('paper 模式没有挂单')
1257
1576
  const exec = request?.[TOOL_CALL]?.exec
1258
1577
  if (!exec?.agent) throw new Error('live 撤单仅允许从 DSH agent 工具调用')
1259
- if (!this.cfg.autonomousLive || !this.cfg.allowedCoins.includes(coin)) await this.authorizeToolAction(`Hyperliquid ${this.cfg.network} 撤销 ${coin} 订单 oid=${oid}?`, exec, `Hyperliquid ${this.cfg.network}: cancel ${coin} order oid=${oid}?`)
1260
- const { universe } = await this.markets()
1261
- const idx = universe.findIndex((u) => u.name === coin)
1262
- if (idx < 0) throw new Error(`未知合约 ${coin}`)
1578
+ if (!this.isManaged() || !this.cfg.allowedCoins.includes(coin)) await this.authorizeToolAction(`Hyperliquid ${this.cfg.network} 撤销 ${coin} 订单 oid=${oid}?`, exec, `Hyperliquid ${this.cfg.network}: cancel ${coin} order oid=${oid}?`)
1579
+ const { asset: idx } = await this.tradeAsset(coin)
1263
1580
  const res = await this.submitAction({ type: 'cancel', cancels: [{ a: idx, o: oid }] }, exec)
1264
1581
  assertExchangeAccepted(res)
1265
1582
  this.store.recordEvent('live.cancel', { coin, oid })
@@ -1270,17 +1587,17 @@ export class HyperRemote extends TypertRemoteService {
1270
1587
  const coin = requireCoin(request)
1271
1588
  const leverage = Number(request?.leverage)
1272
1589
  if (!Number.isInteger(leverage) || leverage < 1 || leverage > 50) throw new Error('leverage 需要是 1-50 的整数')
1273
- const { universe } = await this.markets()
1274
- const idx = universe.findIndex((u) => u.name === coin)
1275
- if (idx < 0) throw new Error(`未知合约 ${coin}`)
1276
- if (leverage > Number(universe[idx].maxLeverage ?? 50)) throw new Error(`杠杆超过 ${coin} 市场上限 ${universe[idx].maxLeverage}x`)
1277
- const isCross = request?.marginMode !== 'isolated'
1590
+ const market = await this.tradeAsset(coin)
1591
+ const idx = market.asset
1592
+ if (leverage > Number(market.maxLeverage ?? 50)) throw new Error(`杠杆超过 ${coin} 市场上限 ${market.maxLeverage}x`)
1593
+ if (market.onlyIsolated && request?.marginMode === 'cross') throw new Error(`${coin} 只支持逐仓`)
1594
+ const isCross = request?.marginMode !== 'isolated' && !market.onlyIsolated
1278
1595
  if (this.cfg.tradingMode === 'paper') {
1279
1596
  if (leverage > this.cfg.maxLeverage) throw new Error(`杠杆超过配置上限 ${this.cfg.maxLeverage}x`)
1280
1597
  if (!this.cfg.allowedCoins.includes(coin)) throw new Error(`${coin} 不在允许交易币对内`)
1281
1598
  const paper = this.store.paperAccount()
1282
1599
  if (paper.positions[coin]) {
1283
- const risk = paperRiskState(paper, await safeMids(this), true)
1600
+ const risk = paperRiskState(paper, await safeMids(this, Object.keys(paper.positions)), true)
1284
1601
  const pos = paper.positions[coin]
1285
1602
  const marked = risk.positions.find((row) => row.coin === coin)
1286
1603
  const currentMargin = Math.abs(pos.size) * marked.markPx / marked.leverage
@@ -1294,7 +1611,7 @@ export class HyperRemote extends TypertRemoteService {
1294
1611
  if (!this.cfg.allowedCoins.includes(coin)) throw new Error(`交易限额:${coin} 不在允许交易币对内(allowedCoins)`)
1295
1612
  const exec = request?.[TOOL_CALL]?.exec
1296
1613
  if (!exec?.agent) throw new Error('live 杠杆变更仅允许从 DSH agent 工具调用')
1297
- if (!this.cfg.autonomousLive) await this.authorizeToolAction(`Hyperliquid ${this.cfg.network} 将 ${coin} 杠杆设为 ${leverage}x?`, exec, `Hyperliquid ${this.cfg.network}: set ${coin} leverage to ${leverage}x?`)
1614
+ if (!this.isManaged()) await this.authorizeToolAction(`Hyperliquid ${this.cfg.network} 将 ${coin} 杠杆设为 ${leverage}x?`, exec, `Hyperliquid ${this.cfg.network}: set ${coin} leverage to ${leverage}x?`)
1298
1615
  const res = await this.submitAction({ type: 'updateLeverage', asset: idx, isCross, leverage }, exec)
1299
1616
  assertExchangeAccepted(res)
1300
1617
  this.store.recordEvent('live.leverage', { coin, leverage, isCross })
@@ -1418,6 +1735,17 @@ function expandHome(p) {
1418
1735
  return resolve(p)
1419
1736
  }
1420
1737
 
1738
+ /** Validate desk wallet settings; missing fields keep the current value. */
1739
+ export function normalizeWalletSettings(input, current = {}) {
1740
+ const signer = input.signer ?? current.signer ?? 'local'
1741
+ if (signer !== 'local' && signer !== 'web3') throw new Error('签名方式必须为 local 或 web3')
1742
+ const web3Account = String(input.web3Account ?? current.web3Account ?? 'main').trim()
1743
+ if (!web3Account || web3Account.length > 80) throw new Error('dsh-web3 账户名无效')
1744
+ const tradingAddress = String(input.tradingAddress ?? current.tradingAddress ?? '').trim()
1745
+ if (tradingAddress && !/^0x[0-9a-fA-F]{40}$/.test(tradingAddress)) throw new Error('主账户地址必须是 0x 开头的 40 位十六进制地址')
1746
+ return { signer, web3Account, tradingAddress }
1747
+ }
1748
+
1421
1749
  function defaultSpanMs(interval) {
1422
1750
  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 }
1423
1751
  return spans[interval] ?? 7 * 86400e3
@@ -1445,15 +1773,17 @@ function argsOf(key) {
1445
1773
  throw new Error(`unknown snapshot key: ${key}`)
1446
1774
  }
1447
1775
 
1448
- async function safeMids(remote) {
1449
- const cached = remote.client.snapshot(keys.allMids())
1450
- if (cached?.data && Date.now() - cached.at < 5_000) return cached.data.mids ?? cached.data
1451
- try {
1776
+ async function safeMids(remote, coins = []) {
1777
+ const core = async () => {
1778
+ const cached = remote.client.snapshot(keys.allMids())
1779
+ if (cached?.data && Date.now() - cached.at < 5_000) return cached.data.mids ?? cached.data
1452
1780
  const res = await remote.client.allMids()
1453
1781
  return res?.mids ?? res ?? {}
1454
- } catch {
1455
- return {}
1456
1782
  }
1783
+ // HIP-3 positions are priced from their own dex's mids.
1784
+ const dexes = [...new Set(coins.filter((coin) => coin.includes(':')).map((coin) => coin.split(':')[0]))]
1785
+ const parts = await Promise.allSettled([core(), ...dexes.map((dex) => remote.dexMids(dex))])
1786
+ return Object.assign({}, ...parts.map((part) => part.status === 'fulfilled' ? part.value : {}))
1457
1787
  }
1458
1788
 
1459
1789
  /** Mark every paper position before computing equity and occupied margin. */
@@ -1486,7 +1816,7 @@ function paperRiskState(paper, mids, strict = false) {
1486
1816
 
1487
1817
  // Remote markers: every method the web client may call.
1488
1818
  for (const m of [
1489
- 'status', 'switchNetwork', 'switchTradingMode', 'updateRiskSettings', 'resetRiskSettings', 'markets', 'marketCatalog', 'marketDetail', 'chartMarks', 'drawChartMark', 'updateChartMark', 'eraseChartMark', 'sessionWorkspace', 'updateSessionWorkspace', 'snapshot', 'candles', 'fundingHistory', 'account', 'fills', 'tradeHistory', 'portfolio',
1819
+ 'indicatorTemplates', 'updatePaperCosts', 'status', 'setCustody', 'walletAccounts', 'updateWalletSettings', 'switchNetwork', 'switchTradingMode', 'updateRiskSettings', 'resetRiskSettings', 'markets', 'marketCatalog', 'marketDetail', 'chartMarks', 'drawChartMark', 'updateChartMark', 'eraseChartMark', 'sessionWorkspace', 'updateSessionWorkspace', 'snapshot', 'candles', 'fundingHistory', 'account', 'fills', 'tradeHistory', 'portfolio',
1490
1820
  'sessions', 'registerSession', 'removeSession', 'bindCoin', 'deskState',
1491
1821
  'eventsSince',
1492
1822
  ]) {