dsh-hyperliquid 0.3.0 → 0.4.1
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/CHANGELOG.md +30 -0
- package/README.md +7 -2
- package/client.js +434 -153
- package/index.js +43 -20
- package/lib/analysis.js +183 -0
- package/lib/chart-drawings.d.ts +2 -1
- package/lib/chart-drawings.js +9 -0
- package/lib/hl.js +6 -0
- package/lib/journal.js +155 -0
- package/lib/prompt.js +92 -0
- package/lib/remote.js +308 -49
- package/lib/risk-settings.js +2 -1
- package/lib/scan.js +91 -0
- package/lib/signing.js +58 -0
- package/lib/skill.js +2 -2
- package/lib/store.js +31 -1
- package/lib/tools.js +134 -23
- package/lib/turn-origin.js +46 -0
- package/lib/wake.js +85 -0
- package/package.json +3 -2
- package/skills/hyperliquid-trading/SKILL.md +119 -45
package/lib/remote.js
CHANGED
|
@@ -11,7 +11,7 @@ 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'
|
|
@@ -128,12 +128,16 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
128
128
|
if (this.cfg.tradingAddress && !/^0x[0-9a-fA-F]{40}$/.test(this.cfg.tradingAddress)) throw new Error('tradingAddress 必须为 20 字节 EVM 地址')
|
|
129
129
|
this.client = new HyperliquidClient({ network: this.cfg.network, privateKey: this.cfg.privateKey })
|
|
130
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 */ }
|
|
131
133
|
this.baseRiskSettings = Object.fromEntries(['allowedCoins', 'maxOrderUsd', 'maxDailyUsd', 'maxPositionUsd', 'maxLeverage'].map((key) => [key, this.cfg[key]]))
|
|
132
134
|
const savedRiskSettings = this.store.getRiskSettings()
|
|
133
135
|
this.riskSettingsOverride = savedRiskSettings != null
|
|
134
136
|
if (savedRiskSettings) Object.assign(this.cfg, normalizeRiskSettings(savedRiskSettings))
|
|
135
137
|
this.guard = new TradingGuard(this.cfg.dataDir, { ...this.cfg, dataDir: this.cfg.dataDir })
|
|
136
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')
|
|
137
141
|
this.marketsCache = null
|
|
138
142
|
this.marketsAt = 0
|
|
139
143
|
this.catalogCache = null
|
|
@@ -146,12 +150,13 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
146
150
|
this.tradeCapacityCache = new Map()
|
|
147
151
|
this.tradeCapacityPending = new Map()
|
|
148
152
|
this.walletAddressCache = null
|
|
153
|
+
this.walletEpoch = 0 // bumped on every wallet switch; work that started under an older wallet is discarded
|
|
149
154
|
this.desk = { coin: 'BTC', interval: '15m', sessionId: null } // active UI session and market focus
|
|
150
155
|
this.lastTouch = new Map() // snapshot key -> last request ms
|
|
151
156
|
this.activeTradeOps = 0
|
|
152
157
|
// Hold the network stable through every order/cancel/leverage operation,
|
|
153
158
|
// including wallet approval and the exchange response.
|
|
154
|
-
for (const method of ['placeOrder', 'cancelOrder', 'setLeverage']) {
|
|
159
|
+
for (const method of ['placeOrder', 'cancelOrder', 'setLeverage', 'transferCollateral']) {
|
|
155
160
|
const operation = this[method].bind(this)
|
|
156
161
|
this[method] = async (...args) => {
|
|
157
162
|
this.activeTradeOps += 1
|
|
@@ -162,7 +167,12 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
162
167
|
this.sweepTimer = setInterval(() => this.sweepSubs(), SUB_SWEEP_MS)
|
|
163
168
|
this.sweepTimer.unref?.()
|
|
164
169
|
this.alertSides = new Map() // drawing id → last side of the mid price (-1 below, 1 above)
|
|
165
|
-
this.
|
|
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)
|
|
166
176
|
this.alertTimer.unref?.()
|
|
167
177
|
}
|
|
168
178
|
|
|
@@ -294,8 +304,63 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
294
304
|
return this.client.exchangeSigned(action, signature, nonce)
|
|
295
305
|
}
|
|
296
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
|
+
|
|
297
357
|
/** Mid price for a perp coin: WS allMids first, REST fallback. */
|
|
298
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
|
+
}
|
|
299
364
|
this.touch(keys.allMids())
|
|
300
365
|
const snap = this.client.snapshot(keys.allMids())
|
|
301
366
|
if (snap?.data && Date.now() - snap.at < 5_000) {
|
|
@@ -361,9 +426,10 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
361
426
|
for (const [index, asset] of (meta?.universe ?? []).entries()) {
|
|
362
427
|
const dex = asset.name.split(':')[0] || 'HIP-3'
|
|
363
428
|
dexIndex.set(asset.name, { dex, index })
|
|
429
|
+
// USDC-collateral HIP-3 perps trade through the same order path (asset 100000 + dex·10000 + index).
|
|
364
430
|
rows.push({ name: asset.name, label: asset.name, kind: 'hip3',
|
|
365
|
-
venue: dex, tradable:
|
|
366
|
-
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 })
|
|
367
433
|
}
|
|
368
434
|
}
|
|
369
435
|
}
|
|
@@ -509,7 +575,7 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
509
575
|
const fields = { ...drawingStyle({ ...patch, kind: current.kind }) }
|
|
510
576
|
if (patch?.alert === true) fields.alertTriggeredAt = undefined
|
|
511
577
|
// null clears a style back to its default (undo of a first restyle relies on this).
|
|
512
|
-
for (const key of ['color', 'lineWidth', 'lineStyle', 'rows', 'valueArea']) 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
|
|
513
579
|
if (geometryKeys.some((key) => key in patch)) Object.assign(fields, drawingGeometry(current.kind, merged))
|
|
514
580
|
if ('label' in patch) {
|
|
515
581
|
const label = String(patch.label ?? '').trim()
|
|
@@ -530,6 +596,15 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
530
596
|
* switches off, the time is recorded) and the desk gets a `chart.alert` event.
|
|
531
597
|
* @param mids - coin → mid (defaults to the live allMids snapshot).
|
|
532
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
|
+
|
|
533
608
|
checkDrawingAlerts(now = Date.now(), mids = null) {
|
|
534
609
|
const watched = this.store.allChartMarks?.().filter((mark) => mark.alert && ALERT_KINDS.includes(mark.kind)) ?? []
|
|
535
610
|
for (const id of [...this.alertSides.keys()]) if (!watched.some((mark) => mark.id === id)) this.alertSides.delete(id)
|
|
@@ -539,7 +614,8 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
539
614
|
const key = this.touch(keys.allMids())
|
|
540
615
|
const snap = this.client.snapshot(key)
|
|
541
616
|
if (!snap || now - snap.at > 10_000) return []
|
|
542
|
-
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)
|
|
543
619
|
}
|
|
544
620
|
const fired = []
|
|
545
621
|
for (const mark of watched) {
|
|
@@ -556,6 +632,8 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
556
632
|
price: mid, line, direction: side > 0 ? 'up' : 'down', at: now }
|
|
557
633
|
this.store.recordEvent('chart.alert', event)
|
|
558
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 */ } }
|
|
559
637
|
}
|
|
560
638
|
return fired
|
|
561
639
|
}
|
|
@@ -696,6 +774,9 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
696
774
|
this.cfg.network = network
|
|
697
775
|
this.client = new HyperliquidClient({ network, privateKey: this.cfg.privateKey })
|
|
698
776
|
this.lastTouch.clear()
|
|
777
|
+
this.dexMidCache?.clear()
|
|
778
|
+
this.alertSides?.clear()
|
|
779
|
+
this.lastLiveAccount = null
|
|
699
780
|
this.marketsCache = null
|
|
700
781
|
this.marketsAt = 0
|
|
701
782
|
this.catalogCache = null
|
|
@@ -721,6 +802,7 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
721
802
|
if (mode === 'live' && request?.acknowledgeLive !== true) throw new Error('切换实盘需要确认')
|
|
722
803
|
const previous = this.cfg.tradingMode
|
|
723
804
|
this.cfg.tradingMode = mode
|
|
805
|
+
this.lastLiveAccount = null
|
|
724
806
|
this.accountModeCache = null
|
|
725
807
|
this.accountModePending = null
|
|
726
808
|
this.tradeCapacityCache.clear()
|
|
@@ -746,6 +828,136 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
746
828
|
return { riskLimits: { ...this.baseRiskSettings, usedTodayUsd: this.usedTodayUsd() } }
|
|
747
829
|
}
|
|
748
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
|
+
|
|
749
961
|
applyRiskSettings(limits, override) {
|
|
750
962
|
Object.assign(this.cfg, limits)
|
|
751
963
|
Object.assign(this.guard.limits, limits)
|
|
@@ -771,7 +983,10 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
771
983
|
network: this.cfg.network,
|
|
772
984
|
tradingMode: this.cfg.tradingMode,
|
|
773
985
|
signer: this.cfg.signer,
|
|
774
|
-
|
|
986
|
+
web3Account: this.cfg.web3Account,
|
|
987
|
+
tradingAddress: this.cfg.tradingAddress,
|
|
988
|
+
autonomousLive: this.isManaged(),
|
|
989
|
+
custody: this.cfg.custody,
|
|
775
990
|
riskSettingsSource: this.riskSettingsOverride ? 'desk' : 'plugin',
|
|
776
991
|
riskLimits: { allowedCoins: this.cfg.allowedCoins, maxOrderUsd: this.cfg.maxOrderUsd, maxDailyUsd: this.cfg.maxDailyUsd, maxPositionUsd: this.cfg.maxPositionUsd, maxLeverage: this.cfg.maxLeverage,
|
|
777
992
|
// Day budget of the ledger that applies to the current mode (paper and live are separate).
|
|
@@ -872,7 +1087,7 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
872
1087
|
async account(request) {
|
|
873
1088
|
if (this.cfg.tradingMode === 'paper') {
|
|
874
1089
|
const paper = this.store.paperAccount()
|
|
875
|
-
const mids = await safeMids(this)
|
|
1090
|
+
const mids = await safeMids(this, Object.keys(paper.positions ?? {}))
|
|
876
1091
|
const risk = paperRiskState(paper, mids)
|
|
877
1092
|
return { mode: 'paper', usd: paper.usd, equity: risk.equity, uPnl: risk.uPnl,
|
|
878
1093
|
marginUsed: risk.marksConfirmed ? risk.marginUsed : null,
|
|
@@ -880,25 +1095,27 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
880
1095
|
marksConfirmed: risk.marksConfirmed, positions: risk.positions, fills: paper.fills.slice(0, 50),
|
|
881
1096
|
simulation: '已模拟配置的滑点与手续费;未模拟盘口深度、资金费或强平' }
|
|
882
1097
|
}
|
|
1098
|
+
const client = this.client, network = this.cfg.network
|
|
1099
|
+
const walletEpoch = this.walletEpoch
|
|
883
1100
|
const address = await this.resolveAddress()
|
|
884
|
-
if (!address) throw new Error('live
|
|
1101
|
+
if (!address) throw new Error('live 模式需要钱包:在交易台 设置 → 交易限额与签名规则 → 签名与托管 中连接')
|
|
885
1102
|
const accountMode = await this.accountMode(address)
|
|
886
1103
|
const sharedBalance = accountMode === 'unifiedAccount' || accountMode === 'portfolioMargin'
|
|
887
1104
|
const activeCoin = canonicalMarketCoin(request?.coin ?? this.desk?.coin)
|
|
888
1105
|
const accountDex = activeCoin.includes(':') ? activeCoin.split(':')[0] : ''
|
|
889
1106
|
const stateKey = this.touch(keys.allDexsClearinghouseState(address))
|
|
890
|
-
const wsState =
|
|
1107
|
+
const wsState = client.snapshot(stateKey)
|
|
891
1108
|
const liveStates = wsState && Date.now() - wsState.at < 10_000 &&
|
|
892
1109
|
wsState.data?.user?.toLowerCase() === address.toLowerCase() && Array.isArray(wsState.data?.clearinghouseStates)
|
|
893
1110
|
? new Map(wsState.data.clearinghouseStates.filter((entry) => Array.isArray(entry) && typeof entry[0] === 'string' && entry[1] && Array.isArray(entry[1].assetPositions)))
|
|
894
1111
|
: null
|
|
895
1112
|
const [state, open, markets, spot] = await Promise.all([
|
|
896
1113
|
liveStates?.get(accountDex) ?? (accountDex
|
|
897
|
-
?
|
|
898
|
-
:
|
|
899
|
-
|
|
1114
|
+
? client.info('clearinghouseState', { user: address, dex: accountDex })
|
|
1115
|
+
: client.clearinghouseState(address)),
|
|
1116
|
+
client.frontendOpenOrders(address),
|
|
900
1117
|
this.markets().catch(() => null),
|
|
901
|
-
sharedBalance ?
|
|
1118
|
+
sharedBalance ? client.spotClearinghouseState(address) : Promise.resolve(null),
|
|
902
1119
|
])
|
|
903
1120
|
if (!state || !Array.isArray(state.assetPositions) || (!sharedBalance && (!state.marginSummary || !Number.isFinite(Number(state.marginSummary.accountValue))))) {
|
|
904
1121
|
throw new Error('交易所永续账户响应无效;账户状态未确认')
|
|
@@ -931,7 +1148,7 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
931
1148
|
maxLeverage: row.position?.maxLeverage ?? null,
|
|
932
1149
|
leverage: row.position?.leverage ?? null,
|
|
933
1150
|
})))
|
|
934
|
-
|
|
1151
|
+
const liveAccount = {
|
|
935
1152
|
mode: 'live',
|
|
936
1153
|
address,
|
|
937
1154
|
accountMode,
|
|
@@ -948,6 +1165,12 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
948
1165
|
positions: assetPositions,
|
|
949
1166
|
openOrders: open,
|
|
950
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
|
|
951
1174
|
}
|
|
952
1175
|
|
|
953
1176
|
/** Open orders (live mode; paper has none resting). */
|
|
@@ -1114,7 +1337,11 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
1114
1337
|
const thesis = String(request?.thesis ?? '').trim()
|
|
1115
1338
|
if (thesis.length < 10 || thesis.length > 1000) throw new Error('交易决策需要 10-1000 字的依据')
|
|
1116
1339
|
const order = orderOf(request)
|
|
1117
|
-
const
|
|
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 }
|
|
1118
1345
|
const decision = this.guard.create(order, sessionId, preview)
|
|
1119
1346
|
this.store.recordLiveDecision(decision.decisionId, { sessionId, order, thesis, preview, expiresAt: decision.expiresAt })
|
|
1120
1347
|
return decision
|
|
@@ -1129,19 +1356,37 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
1129
1356
|
const address = await this.resolveAddress()
|
|
1130
1357
|
if (!address) throw new Error('live 交易需要有效的 tradingAddress 或本地钱包')
|
|
1131
1358
|
if (this.cfg.signer === 'web3') await this.web3Address()
|
|
1132
|
-
const
|
|
1133
|
-
|
|
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),
|
|
1134
1365
|
])
|
|
1135
1366
|
if (!Array.isArray(state?.assetPositions)) throw new Error('交易所持仓状态未确认,拒绝 live 下单')
|
|
1136
1367
|
const sharedBalance = accountMode === 'unifiedAccount' || accountMode === 'portfolioMargin'
|
|
1137
|
-
|
|
1138
|
-
if (
|
|
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
|
+
}
|
|
1139
1384
|
const mid = Number(mids?.mids?.[order.coin] ?? mids?.[order.coin])
|
|
1140
1385
|
if (!Number.isFinite(mid) || mid <= 0) throw new Error('无法获取实时价格,拒绝 live 下单')
|
|
1141
1386
|
validateProtection(order, mid)
|
|
1142
1387
|
if (Number(wireSz(order.sz, asset.szDecimals)) !== order.sz) throw new Error(`数量不符合 ${order.coin} 的精度 ${asset.szDecimals}`)
|
|
1143
1388
|
const row = (state?.assetPositions ?? []).find((p) => p.position?.coin === order.coin)?.position
|
|
1144
|
-
const otherExposureUsd = (
|
|
1389
|
+
const otherExposureUsd = positionStates.flatMap((value) => value.assetPositions).reduce((sum, p) => {
|
|
1145
1390
|
if (p.position?.coin === order.coin) return sum
|
|
1146
1391
|
const value = Number(p.position?.positionValue)
|
|
1147
1392
|
if (!Number.isFinite(value)) throw new Error('无法读取其他仓位敞口')
|
|
@@ -1161,10 +1406,13 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
1161
1406
|
const sideCapacity = Math.min(...capacity.maxTradeSzs.map(Number))
|
|
1162
1407
|
if (!order.reduceOnly && order.sz > sideCapacity + 1e-10) throw new Error('订单数量超过交易所当前可交易额度')
|
|
1163
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
|
+
}
|
|
1164
1412
|
const riskPrice = order.type === 'limit' ? Math.max(mid * 1.05, order.px) : mid * 1.05
|
|
1165
1413
|
const checked = this.guard.validate({ ...order, price: riskPrice, positionSize, otherExposureUsd, accountValue, leverage })
|
|
1166
1414
|
if (client !== this.client || network !== this.cfg.network) throw new Error('网络已切换,请重新预检交易')
|
|
1167
|
-
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 }
|
|
1168
1416
|
}
|
|
1169
1417
|
|
|
1170
1418
|
async executeToolOrder(request, exec, sessionId) {
|
|
@@ -1201,8 +1449,7 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
1201
1449
|
const reduceOnly = Boolean(request?.reduceOnly)
|
|
1202
1450
|
if (this.cfg.tradingMode === 'paper') {
|
|
1203
1451
|
if (request?.takeProfitPx != null || request?.stopLossPx != null) throw new Error('paper 暂不模拟止盈止损触发单;请移除保护价格或切换 testnet live')
|
|
1204
|
-
|
|
1205
|
-
if (!universe.some((row) => row.name === coin)) throw new Error(`当前仅支持核心永续合约模拟交易:${coin}`)
|
|
1452
|
+
await this.tradeAsset(coin)
|
|
1206
1453
|
const mid = await this.midOf(coin)
|
|
1207
1454
|
const pos = this.store.paper.positions[coin]?.size ?? 0
|
|
1208
1455
|
if (reduceOnly && (pos === 0 || Math.sign(isBuy ? sz : -sz) === Math.sign(pos) || sz > Math.abs(pos) + 1e-10)) throw new Error('只减仓订单会增加或翻转仓位')
|
|
@@ -1217,7 +1464,7 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
1217
1464
|
const px = mid * (isBuy ? 1 + pct : 1 - pct)
|
|
1218
1465
|
if (request?.type === 'limit' && (isBuy ? px > Number(request.px) : px < Number(request.px))) throw new Error('paper 模拟滑点后无法满足限价')
|
|
1219
1466
|
const paper = this.store.paperAccount()
|
|
1220
|
-
const risk = paperRiskState(paper, { ...await safeMids(this), [coin]: mid }, true)
|
|
1467
|
+
const risk = paperRiskState(paper, { ...await safeMids(this, Object.keys(paper.positions)), [coin]: mid }, true)
|
|
1221
1468
|
const positionSize = Number(paper.positions[coin]?.size ?? 0)
|
|
1222
1469
|
const leverage = Number(paper.positions[coin]?.leverage ?? paper.leverageByCoin?.[coin] ?? 1)
|
|
1223
1470
|
const otherExposureUsd = risk.positions.reduce((sum, p) => sum + (p.coin === coin ? 0 : Math.abs(p.size) * p.markPx), 0)
|
|
@@ -1244,16 +1491,17 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
1244
1491
|
const orderSpec = orderOf(request)
|
|
1245
1492
|
const preview = this.guard.consume(request?.decisionId, orderSpec, trusted.sessionId)
|
|
1246
1493
|
if (preview.network !== this.cfg.network) throw new Error('交易决策的网络与当前网络不一致,请重新预检')
|
|
1494
|
+
if (preview.walletEpoch !== this.walletEpoch) throw new Error('交易决策来自切换前的钱包,请重新预检')
|
|
1247
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
|
+
}
|
|
1248
1499
|
if (Math.abs(fresh.mid / preview.mid - 1) > 0.01) throw new Error('价格已偏离预检超过 1%,请重新决策')
|
|
1249
|
-
if (!this.
|
|
1500
|
+
if (!this.isManaged()) {
|
|
1250
1501
|
await this.authorizeToolAction(`Hyperliquid ${this.cfg.network} ${coin} ${isBuy ? '买入' : '卖出'} ${sz},预估名义价值 ${fresh.notionalUsd.toFixed(2)} USD。是否允许本次交易?`, trusted.exec,
|
|
1251
1502
|
`Hyperliquid ${this.cfg.network}: ${isBuy ? 'buy' : 'sell'} ${sz} ${coin}, estimated notional ${fresh.notionalUsd.toFixed(2)} USD. Allow this trade?`)
|
|
1252
1503
|
}
|
|
1253
|
-
const {
|
|
1254
|
-
const idx = universe.findIndex((u) => u.name === coin)
|
|
1255
|
-
if (idx < 0) throw new Error(`未知合约 ${coin}`)
|
|
1256
|
-
const szDecimals = universe[idx].szDecimals
|
|
1504
|
+
const { asset: idx, szDecimals } = await this.tradeAsset(coin)
|
|
1257
1505
|
const tif = request?.type === 'limit' ? (request?.tif === 'Alo' ? 'Alo' : 'Gtc') : 'Ioc'
|
|
1258
1506
|
const mid = await this.midOf(coin)
|
|
1259
1507
|
let pxNum
|
|
@@ -1327,10 +1575,8 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
1327
1575
|
if (this.cfg.tradingMode === 'paper') throw new Error('paper 模式没有挂单')
|
|
1328
1576
|
const exec = request?.[TOOL_CALL]?.exec
|
|
1329
1577
|
if (!exec?.agent) throw new Error('live 撤单仅允许从 DSH agent 工具调用')
|
|
1330
|
-
if (!this.
|
|
1331
|
-
const {
|
|
1332
|
-
const idx = universe.findIndex((u) => u.name === coin)
|
|
1333
|
-
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)
|
|
1334
1580
|
const res = await this.submitAction({ type: 'cancel', cancels: [{ a: idx, o: oid }] }, exec)
|
|
1335
1581
|
assertExchangeAccepted(res)
|
|
1336
1582
|
this.store.recordEvent('live.cancel', { coin, oid })
|
|
@@ -1341,17 +1587,17 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
1341
1587
|
const coin = requireCoin(request)
|
|
1342
1588
|
const leverage = Number(request?.leverage)
|
|
1343
1589
|
if (!Number.isInteger(leverage) || leverage < 1 || leverage > 50) throw new Error('leverage 需要是 1-50 的整数')
|
|
1344
|
-
const
|
|
1345
|
-
const idx =
|
|
1346
|
-
if (
|
|
1347
|
-
if (
|
|
1348
|
-
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
|
|
1349
1595
|
if (this.cfg.tradingMode === 'paper') {
|
|
1350
1596
|
if (leverage > this.cfg.maxLeverage) throw new Error(`杠杆超过配置上限 ${this.cfg.maxLeverage}x`)
|
|
1351
1597
|
if (!this.cfg.allowedCoins.includes(coin)) throw new Error(`${coin} 不在允许交易币对内`)
|
|
1352
1598
|
const paper = this.store.paperAccount()
|
|
1353
1599
|
if (paper.positions[coin]) {
|
|
1354
|
-
const risk = paperRiskState(paper, await safeMids(this), true)
|
|
1600
|
+
const risk = paperRiskState(paper, await safeMids(this, Object.keys(paper.positions)), true)
|
|
1355
1601
|
const pos = paper.positions[coin]
|
|
1356
1602
|
const marked = risk.positions.find((row) => row.coin === coin)
|
|
1357
1603
|
const currentMargin = Math.abs(pos.size) * marked.markPx / marked.leverage
|
|
@@ -1365,7 +1611,7 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
1365
1611
|
if (!this.cfg.allowedCoins.includes(coin)) throw new Error(`交易限额:${coin} 不在允许交易币对内(allowedCoins)`)
|
|
1366
1612
|
const exec = request?.[TOOL_CALL]?.exec
|
|
1367
1613
|
if (!exec?.agent) throw new Error('live 杠杆变更仅允许从 DSH agent 工具调用')
|
|
1368
|
-
if (!this.
|
|
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?`)
|
|
1369
1615
|
const res = await this.submitAction({ type: 'updateLeverage', asset: idx, isCross, leverage }, exec)
|
|
1370
1616
|
assertExchangeAccepted(res)
|
|
1371
1617
|
this.store.recordEvent('live.leverage', { coin, leverage, isCross })
|
|
@@ -1489,6 +1735,17 @@ function expandHome(p) {
|
|
|
1489
1735
|
return resolve(p)
|
|
1490
1736
|
}
|
|
1491
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
|
+
|
|
1492
1749
|
function defaultSpanMs(interval) {
|
|
1493
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 }
|
|
1494
1751
|
return spans[interval] ?? 7 * 86400e3
|
|
@@ -1516,15 +1773,17 @@ function argsOf(key) {
|
|
|
1516
1773
|
throw new Error(`unknown snapshot key: ${key}`)
|
|
1517
1774
|
}
|
|
1518
1775
|
|
|
1519
|
-
async function safeMids(remote) {
|
|
1520
|
-
const
|
|
1521
|
-
|
|
1522
|
-
|
|
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
|
|
1523
1780
|
const res = await remote.client.allMids()
|
|
1524
1781
|
return res?.mids ?? res ?? {}
|
|
1525
|
-
} catch {
|
|
1526
|
-
return {}
|
|
1527
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 : {}))
|
|
1528
1787
|
}
|
|
1529
1788
|
|
|
1530
1789
|
/** Mark every paper position before computing equity and occupied margin. */
|
|
@@ -1557,7 +1816,7 @@ function paperRiskState(paper, mids, strict = false) {
|
|
|
1557
1816
|
|
|
1558
1817
|
// Remote markers: every method the web client may call.
|
|
1559
1818
|
for (const m of [
|
|
1560
|
-
'indicatorTemplates', 'updatePaperCosts', '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',
|
|
1561
1820
|
'sessions', 'registerSession', 'removeSession', 'bindCoin', 'deskState',
|
|
1562
1821
|
'eventsSince',
|
|
1563
1822
|
]) {
|
package/lib/risk-settings.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { canonicalMarketCoin } from './market-symbol.js'
|
|
1
2
|
/** Validate the user-editable trading limits before they reach either guard. */
|
|
2
3
|
export function normalizeRiskSettings(input) {
|
|
3
4
|
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('交易设置无效')
|
|
@@ -5,7 +6,7 @@ export function normalizeRiskSettings(input) {
|
|
|
5
6
|
if (Object.keys(input).some((key) => !allowed.includes(key)) || allowed.some((key) => !(key in input))) throw new Error('交易设置字段无效')
|
|
6
7
|
const coins = typeof input.allowedCoins === 'string' ? input.allowedCoins.split(',') : input.allowedCoins
|
|
7
8
|
if (!Array.isArray(coins)) throw new Error('允许交易的币对无效')
|
|
8
|
-
const allowedCoins = [...new Set(coins.map((coin) =>
|
|
9
|
+
const allowedCoins = [...new Set(coins.map((coin) => canonicalMarketCoin(coin)).filter(Boolean))]
|
|
9
10
|
if (!allowedCoins.length || allowedCoins.length > 100 || allowedCoins.some((coin) => coin.length > 32 || !/^[A-Za-z0-9:_-]+$/.test(coin))) {
|
|
10
11
|
throw new Error('允许交易的币对无效')
|
|
11
12
|
}
|