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