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/CHANGELOG.md +25 -0
- package/README.md +20 -9
- package/client.js +2302 -628
- package/docs/research-review-2026-10-16.md +46 -0
- package/index.js +43 -19
- package/lib/analysis.js +183 -0
- package/lib/chart-drawings.d.ts +7 -2
- package/lib/chart-drawings.js +67 -0
- package/lib/hl.js +6 -0
- package/lib/journal.js +155 -0
- package/lib/prompt.js +92 -0
- package/lib/remote.js +410 -80
- package/lib/review.d.ts +3 -0
- package/lib/review.js +79 -0
- 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 +68 -8
- package/lib/studies.d.ts +13 -0
- package/lib/studies.js +157 -0
- package/lib/tools.js +191 -35
- package/lib/turn-origin.js +46 -0
- package/lib/wake.js +85 -0
- package/lib/workspace-settings.d.ts +3 -0
- package/lib/workspace-settings.js +12 -0
- package/package.json +8 -4
- package/skills/hyperliquid-trading/SKILL.md +106 -27
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.
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
?
|
|
852
|
-
:
|
|
853
|
-
|
|
1114
|
+
? client.info('clearinghouseState', { user: address, dex: accountDex })
|
|
1115
|
+
: client.clearinghouseState(address)),
|
|
1116
|
+
client.frontendOpenOrders(address),
|
|
854
1117
|
this.markets().catch(() => null),
|
|
855
|
-
sharedBalance ?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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),
|
|
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
|
|
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
|
|
1064
|
-
|
|
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
|
-
|
|
1069
|
-
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
|
+
}
|
|
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 = (
|
|
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
|
-
|
|
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
|
|
1146
|
-
const
|
|
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]:
|
|
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) *
|
|
1157
|
-
const projectedMargin = risk.marginUsed - currentMargin + Math.abs(checked.nextSize) *
|
|
1158
|
-
|
|
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,
|
|
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.
|
|
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 {
|
|
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.
|
|
1260
|
-
const {
|
|
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
|
|
1274
|
-
const idx =
|
|
1275
|
-
if (
|
|
1276
|
-
if (
|
|
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.
|
|
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
|
|
1450
|
-
|
|
1451
|
-
|
|
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
|
]) {
|