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/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
|
2
|
+
<!-- Official Hyperliquid mark (brand kit "Hyperliquid SVG format.zip",
|
|
3
|
+
Hyperliquid_Blob_Green.svg) on the official brand-dark plate.
|
|
4
|
+
Source: https://hyperliquid.gitbook.io/hyperliquid-docs/brand-kit -->
|
|
5
|
+
<rect width="200" height="200" rx="44" fill="#011916"/>
|
|
6
|
+
<path d="M175.11589,99.27891c0,49.46366-30.3687,65.33789-46.47302,51.30413-13.11357-11.5031-17.02471-35.88989-36.81018-38.42055-25.07698-2.99085-27.37764,30.36839-43.94216,30.36839-19.3254,0-23.00642-27.83773-23.00642-42.33179,0-14.72405,4.14115-34.73961,20.47565-34.73961,19.09528,0,20.24566,28.75802,44.17226,27.14754,23.69661-1.61047,24.1567-31.51877,39.80103-44.17224,13.57335-11.27313,45.78283.69019,45.78283,50.84413Z" fill="#97fce4"/>
|
|
7
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-hyperliquid — host half.
|
|
3
|
+
*
|
|
4
|
+
* Publishes the `hyper` Remote namespace (Typert, dsh-bots-anchored), registers
|
|
5
|
+
* the hyper_* agent tools, and contributes a short system-prompt section so
|
|
6
|
+
* agents in any session know the trading desk exists and how to drive it.
|
|
7
|
+
* The client half (client.js) renders the peer-level trading platform.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-hyperliquid
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { HyperRemote } from './lib/remote.js'
|
|
13
|
+
import { buildToolDefinitions } from './lib/tools.js'
|
|
14
|
+
import { registerHyperSkill } from './lib/skill.js'
|
|
15
|
+
|
|
16
|
+
export const name = 'dsh-hyperliquid'
|
|
17
|
+
|
|
18
|
+
/** Optional services only: without `tools` the desk still serves the UI. */
|
|
19
|
+
export const inject = []
|
|
20
|
+
|
|
21
|
+
let z = null
|
|
22
|
+
try {
|
|
23
|
+
// Prefer the HOST's schemastery copy (same implementation the loader
|
|
24
|
+
// validates with) resolved through the dsh-bin anchor; fall back to the
|
|
25
|
+
// bundled copy. require(esm) — Node ≥ 22.12.
|
|
26
|
+
const { createRequire } = await import('node:module')
|
|
27
|
+
const { realpathSync } = await import('node:fs')
|
|
28
|
+
try {
|
|
29
|
+
const anchor = realpathSync(process.argv?.[1] ?? '')
|
|
30
|
+
z = createRequire(anchor)('@deepseek-ai/schemastery')
|
|
31
|
+
if (!z || typeof z.object !== 'function') z = null
|
|
32
|
+
} catch { /* anchor unavailable → bundled copy */ }
|
|
33
|
+
if (!z) z = (await import('@deepseek-ai/schemastery')).default
|
|
34
|
+
} catch (err) {
|
|
35
|
+
throw new Error(`[dsh-hyperliquid] schemastery 加载失败:${String(err?.message ?? err)}`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const Config = z.object({
|
|
39
|
+
dataDir: z.string().description('插件数据目录(交易会话登记表与 paper 账户落盘处)').default('~/.dsh-hyperliquid'),
|
|
40
|
+
network: z.union([z.const('mainnet'), z.const('testnet')]).description('Hyperliquid 网络,默认 mainnet;testnet 可在交易台「更多」中切换').default('mainnet'),
|
|
41
|
+
tradingMode: z.union([z.const('paper'), z.const('live')]).description('交易模式:live 真实签名下单(默认),paper 本地撮合;可在交易台「更多」中切换').default('live'),
|
|
42
|
+
privateKey: z.string().description('Hyperliquid 钱包私钥(signer=local 时使用;建议用 agent wallet 派生密钥;留空则 live 不可用)。也可用环境变量 HYPERLIQUID_PRIVATE_KEY').default(''),
|
|
43
|
+
signer: z.union([z.const('local'), z.const('web3')]).description('签名方式:local 插件内私钥签名;web3 交给 dsh-web3 钱包保险库签名(私钥不出保险库,逐笔过签名策略与审批)').default('local'),
|
|
44
|
+
web3Account: z.string().description('signer=web3 时使用的 dsh-web3 账户 id 或名称').default('main'),
|
|
45
|
+
tradingAddress: z.string().description('Hyperliquid 主账户地址;使用 dsh-web3 API wallet 签名时必填,账户查询必须用主账户地址').default(''),
|
|
46
|
+
autonomousLive: z.boolean().description('允许 agent 在硬性限额内自主提交 live 订单;默认关闭,且仅支持 dsh-web3 签名').default(false),
|
|
47
|
+
allowedCoins: z.string().description('交易限额(paper/live 均生效):允许新开仓的币对,逗号分隔;减仓不受限').default('BTC,ETH'),
|
|
48
|
+
maxOrderUsd: z.number().description('交易限额(paper/live 均生效):单笔名义价值上限 USD').default(100),
|
|
49
|
+
maxDailyUsd: z.number().description('交易限额(paper/live 均生效):每 UTC 日累计开仓名义价值上限 USD,paper 与 live 分账').default(500),
|
|
50
|
+
maxPositionUsd: z.number().description('交易限额(paper/live 均生效):每币最大仓位名义价值 USD').default(500),
|
|
51
|
+
maxLeverage: z.number().description('交易限额(paper/live 均生效):最大杠杆倍数').default(2),
|
|
52
|
+
}).description('Hyperliquid 交易台配置')
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param ctx - the Cordis plugin context.
|
|
56
|
+
* @param config - validated row config.
|
|
57
|
+
*/
|
|
58
|
+
export function apply(ctx, config = {}) {
|
|
59
|
+
const remote = new HyperRemote(ctx, config)
|
|
60
|
+
const disposers = []
|
|
61
|
+
|
|
62
|
+
// Live channel lifecycle: stop the upstream WS on teardown.
|
|
63
|
+
ctx.effect(() => () => remote.stopWs())
|
|
64
|
+
|
|
65
|
+
// Agent tools (optional service — degrade gracefully).
|
|
66
|
+
const tools = typeof ctx?.get === 'function' ? ctx.get('tools') : undefined
|
|
67
|
+
if (tools && typeof tools.register === 'function') {
|
|
68
|
+
for (const def of buildToolDefinitions(remote)) {
|
|
69
|
+
const remove = tools.register(def)
|
|
70
|
+
disposers.push(remove)
|
|
71
|
+
ctx.effect(() => () => { try { remove() } catch { /* already gone */ } })
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
ctx.logger?.warn?.('[dsh-hyperliquid] tools 服务不可用,仅提供 UI 远端')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const skillRemove = registerHyperSkill(typeof ctx?.get === 'function' ? ctx.get('skills') : null)
|
|
78
|
+
if (skillRemove) {
|
|
79
|
+
disposers.push(skillRemove)
|
|
80
|
+
ctx.effect(() => () => { try { skillRemove() } catch { /* already gone */ } })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// System-prompt section: make the desk discoverable to every agent, with
|
|
84
|
+
// LIVE desk context (what the user is looking at right now) evaluated per
|
|
85
|
+
// prompt — the desk and the conversation stay in lockstep both ways.
|
|
86
|
+
const systemPrompt = typeof ctx?.get === 'function' ? ctx.get('systemPrompt') : undefined
|
|
87
|
+
if (systemPrompt && typeof systemPrompt.section === 'function') {
|
|
88
|
+
const remove = systemPrompt.section({
|
|
89
|
+
name: 'hyperliquid-desk',
|
|
90
|
+
order: 500,
|
|
91
|
+
text: () => {
|
|
92
|
+
const mode = remote.cfg.tradingMode
|
|
93
|
+
const net = remote.cfg.network
|
|
94
|
+
const lines = [
|
|
95
|
+
'## Hyperliquid 交易台(dsh-hyperliquid)',
|
|
96
|
+
`本 Harness 装有 Hyperliquid 交易台(mode=${mode},network=${net})。用户可在面板看行情并填写交易票据;票据只把意图送入当前会话,所有实际下单只能由你调用工具完成。`,
|
|
97
|
+
'可用工具:hyper_context(会话交易上下文)、hyper_market(详细行情)、hyper_chart(在行情图上画支撑/阻力与趋势线)、hyper_account(账户)、hyper_decide(交易决策预检并返回一次性 decisionId)、hyper_order(执行/撤单/杠杆)、hyper_session(会话视角)。',
|
|
98
|
+
'用户说"当前/界面上这个币"时,先 hyper_context 或 hyper_session status;需要技术判断时用 hyper_market indicators 验证趋势、动量、波动和支撑阻力,可用 hyper_chart 将依据画给用户看。交易前核对行情与账户,说明方向、数量、依据及风险。paper 模式用 hyper_order 执行;live 模式先用 hyper_decide 预检,并用返回的一次性 decisionId 执行 hyper_order。是否可自主执行由宿主配置和钱包策略决定。',
|
|
99
|
+
]
|
|
100
|
+
if (skillRemove) lines.push('复杂的 Hyperliquid 分析、交易或异常对账,请先加载 hyperliquid-trading skill。')
|
|
101
|
+
// Live desk focus: what coin/interval the desk shows right now.
|
|
102
|
+
try {
|
|
103
|
+
const desk = remote.desk ?? {}
|
|
104
|
+
if (desk.coin) lines.push(`当前交易台焦点:${desk.coin} · K线 ${desk.interval ?? '15m'}。用户消息里说"这个币/当前价格/图上"时指的就是它。`)
|
|
105
|
+
} catch { /* desk context is best-effort */ }
|
|
106
|
+
return lines.join('\n')
|
|
107
|
+
},
|
|
108
|
+
})
|
|
109
|
+
disposers.push(remove)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return () => {
|
|
113
|
+
for (const d of disposers) {
|
|
114
|
+
try { d() } catch { /* best effort */ }
|
|
115
|
+
}
|
|
116
|
+
remote.stopWs()
|
|
117
|
+
}
|
|
118
|
+
}
|
package/lib/hl.js
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// lib/hl.js — Hyperliquid market-data layer, SDK-backed.
|
|
2
|
+
//
|
|
3
|
+
// Data plane (info + WS) runs on the community-standard TypeScript SDK
|
|
4
|
+
// `@nktkas/hyperliquid` (MIT): HttpTransport for /info requests and
|
|
5
|
+
// WebSocketTransport + SubscriptionClient for live channels. The SDK brings
|
|
6
|
+
// schema-validated requests, automatic reconnection and automatic
|
|
7
|
+
// re-subscription after reconnect — the three things the previous hand-rolled
|
|
8
|
+
// wire layer lacked.
|
|
9
|
+
//
|
|
10
|
+
// The execution plane (/exchange) stays on our own EIP-712 signing
|
|
11
|
+
// implementation (lib/signing.js), which is byte-for-byte validated against
|
|
12
|
+
// the official hyperliquid-python-sdk (see test/signing.test.mjs).
|
|
13
|
+
//
|
|
14
|
+
// Public surface (consumed by lib/remote.js): HyperliquidClient, keys, sigFigs.
|
|
15
|
+
// Snapshot keys and payload shapes are unchanged, so the remote layer and the
|
|
16
|
+
// web desk are source-compatible.
|
|
17
|
+
|
|
18
|
+
import { canonicalAction, signL1Action } from './signing.js'
|
|
19
|
+
|
|
20
|
+
const MAINNET = 'https://api.hyperliquid.xyz'
|
|
21
|
+
const TESTNET = 'https://api.hyperliquid-testnet.xyz'
|
|
22
|
+
const WS_MAINNET = 'wss://api.hyperliquid.xyz/ws'
|
|
23
|
+
const WS_TESTNET = 'wss://api.hyperliquid-testnet.xyz/ws'
|
|
24
|
+
|
|
25
|
+
function quietUnsubscribe(channel) {
|
|
26
|
+
try { Promise.resolve(channel?.unsubscribe()).catch(() => {}) }
|
|
27
|
+
catch { /* transport already closing */ }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Round to `maxDecimals` with at most `n` significant figures. */
|
|
31
|
+
export function sigFigs(value, n, maxDecimals) {
|
|
32
|
+
const num = Number(value)
|
|
33
|
+
if (!Number.isFinite(num) || num === 0) return '0'
|
|
34
|
+
const abs = Math.abs(num)
|
|
35
|
+
const digits = Math.floor(Math.log10(abs)) + 1
|
|
36
|
+
const decimals = Math.max(0, Math.min(maxDecimals, n - digits))
|
|
37
|
+
return num.toFixed(decimals)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function postJson(url, body, timeoutMs = 10_000) {
|
|
41
|
+
const ac = new AbortController()
|
|
42
|
+
const timer = setTimeout(() => ac.abort(new Error('timeout')), timeoutMs)
|
|
43
|
+
try {
|
|
44
|
+
const res = await fetch(url, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { 'content-type': 'application/json' },
|
|
47
|
+
body: JSON.stringify(body),
|
|
48
|
+
signal: ac.signal,
|
|
49
|
+
})
|
|
50
|
+
const data = await res.json().catch(() => null)
|
|
51
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${JSON.stringify(data)?.slice(0, 300)}`)
|
|
52
|
+
return data
|
|
53
|
+
} finally {
|
|
54
|
+
clearTimeout(timer)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* One Hyperliquid endpoint pair (REST + WS) with snapshot caches.
|
|
60
|
+
* The trading desk instantiates exactly one; clients poll snapshots from it.
|
|
61
|
+
*
|
|
62
|
+
* Live channels are ref-counted: remote.js calls subscribe(key)/unsubscribe(key)
|
|
63
|
+
* as coins gain/lose focus; every callback just refreshes the snapshot map.
|
|
64
|
+
*/
|
|
65
|
+
export class HyperliquidClient {
|
|
66
|
+
/**
|
|
67
|
+
* @param {{network?: 'mainnet'|'testnet', privateKey?: string}} opts
|
|
68
|
+
*/
|
|
69
|
+
constructor(opts = {}) {
|
|
70
|
+
this.network = opts.network === 'mainnet' ? 'mainnet' : 'testnet'
|
|
71
|
+
this.restBase = this.network === 'mainnet' ? MAINNET : TESTNET
|
|
72
|
+
this.wsBase = this.network === 'mainnet' ? WS_MAINNET : WS_TESTNET
|
|
73
|
+
this.privateKey = opts.privateKey ?? ''
|
|
74
|
+
// A signer must never reuse a nonce, including concurrent order/cancel calls.
|
|
75
|
+
this.lastNonce = 0
|
|
76
|
+
|
|
77
|
+
// ── SDK transports (lazy: nothing connects until first use) ──
|
|
78
|
+
this._sdk = null // { HttpTransport, WebSocketTransport, SubscriptionClient, info: {...fns} }
|
|
79
|
+
this._http = null
|
|
80
|
+
this._wsTransport = null
|
|
81
|
+
this._subsClient = null
|
|
82
|
+
this._liveSubs = new Map() // snapshot key -> ISubscription
|
|
83
|
+
|
|
84
|
+
this.ws = null // legacy compat field (unused by the SDK path)
|
|
85
|
+
this.wsState = 'closed' // closed | idle | connecting | open
|
|
86
|
+
this.subs = new Map() // key -> {args, listeners:number}
|
|
87
|
+
this.snapshots = new Map() // key -> latest payload
|
|
88
|
+
this.snapshotTimes = new Map() // key -> last exchange message received on host
|
|
89
|
+
this.seqs = new Map() // key -> monotonically increasing revision
|
|
90
|
+
this.throws = 0
|
|
91
|
+
this.lastWsError = null
|
|
92
|
+
this.pingTimer = null // legacy compat (SDK keep-alive handles ping)
|
|
93
|
+
this.retryTimers = new Map() // failed channel key -> retry timer
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── SDK bootstrap ────────────────────────────────────────────────────
|
|
97
|
+
_sdkOnce() {
|
|
98
|
+
if (this._sdk) return this._sdk
|
|
99
|
+
// Plain dynamic imports keep the SDK optional at parse time and let the
|
|
100
|
+
// desk degrade to REST-only polling if the package is unavailable.
|
|
101
|
+
const root = '@nktkas/hyperliquid'
|
|
102
|
+
const infoApi = `${root}/api/info`
|
|
103
|
+
const subApi = `${root}/api/subscription`
|
|
104
|
+
// Deliberately not awaited: transports are created eagerly-ish but callers
|
|
105
|
+
// await the individual request methods, which await their modules.
|
|
106
|
+
this._sdk = Promise.all([import(root), import(infoApi), import(subApi)]).then(([r, i, s]) => {
|
|
107
|
+
this._http = new r.HttpTransport({ isTestnet: this.network !== 'mainnet' })
|
|
108
|
+
const cfg = { transport: this._http }
|
|
109
|
+
this._sdkInfo = {
|
|
110
|
+
meta: () => i.meta(cfg),
|
|
111
|
+
metaAndAssetCtxs: () => i.metaAndAssetCtxs(cfg),
|
|
112
|
+
allMids: () => i.allMids(cfg),
|
|
113
|
+
l2Book: (coin, grouping = null) => i.l2Book(cfg, { coin, ...(grouping ?? {}) }),
|
|
114
|
+
candleSnapshot: (coin, interval, startTime, endTime) => i.candleSnapshot(cfg, { coin, interval, startTime, endTime }),
|
|
115
|
+
clearinghouseState: (user) => i.clearinghouseState(cfg, { user }),
|
|
116
|
+
spotClearinghouseState: (user) => i.spotClearinghouseState(cfg, { user }),
|
|
117
|
+
userAbstraction: (user) => i.userAbstraction(cfg, { user }),
|
|
118
|
+
activeAssetData: (user, coin) => i.activeAssetData(cfg, { user, coin }),
|
|
119
|
+
openOrders: (user) => i.openOrders(cfg, { user }),
|
|
120
|
+
frontendOpenOrders: (user) => i.frontendOpenOrders(cfg, { user }),
|
|
121
|
+
userFills: (user) => i.userFills(cfg, { user }),
|
|
122
|
+
historicalOrders: (user) => i.historicalOrders(cfg, { user }),
|
|
123
|
+
portfolio: (user) => i.portfolio(cfg, { user }),
|
|
124
|
+
}
|
|
125
|
+
this._wsTransport = new r.WebSocketTransport({ url: this.wsBase })
|
|
126
|
+
this._subsClient = new r.SubscriptionClient({ transport: this._wsTransport })
|
|
127
|
+
// Connection bookkeeping: the transport's event target carries the
|
|
128
|
+
// system events; ready() resolves once the socket is up.
|
|
129
|
+
const hlEvents = this._wsTransport._hlEvents
|
|
130
|
+
hlEvents?.addEventListener?.('error', (ev) => {
|
|
131
|
+
this.lastWsError = ev?.detail ?? 'ws error'
|
|
132
|
+
this.wsState = 'connecting'
|
|
133
|
+
})
|
|
134
|
+
hlEvents?.addEventListener?.('subscriptionResponse', () => {
|
|
135
|
+
// Unsubscribe acks arrive here too; with no channels left we are idle.
|
|
136
|
+
if (this.wsState !== 'closed') this.wsState = this.subs.size ? 'open' : 'idle'
|
|
137
|
+
})
|
|
138
|
+
// The SDK socket reconnects and resubscribes on its own; mirror that so
|
|
139
|
+
// a dropped upstream reads as "connecting" rather than a silent "open".
|
|
140
|
+
const socket = this._wsTransport.socket
|
|
141
|
+
socket?.addEventListener?.('close', () => { if (this.wsState !== 'closed') this.wsState = 'connecting' })
|
|
142
|
+
socket?.addEventListener?.('open', () => { if (this.wsState === 'connecting') this.wsState = this.subs.size ? 'open' : 'idle' })
|
|
143
|
+
Promise.resolve(this._wsTransport.ready?.()).then(() => {
|
|
144
|
+
this.wsState = 'open'
|
|
145
|
+
}).catch((err) => {
|
|
146
|
+
this.lastWsError = String(err?.message ?? err)
|
|
147
|
+
})
|
|
148
|
+
return this._sdkInfo
|
|
149
|
+
})
|
|
150
|
+
return this._sdk
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
_infoFns() {
|
|
154
|
+
return this._sdkOnce().then((fns) => {
|
|
155
|
+
if (!fns) throw new Error('SDK 初始化失败(@nktkas/hyperliquid 不可用)')
|
|
156
|
+
return fns
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── /info ────────────────────────────────────────────────────────────
|
|
161
|
+
/** Generic /info passthrough with a timeout. `type` values per docs. */
|
|
162
|
+
info(type, extra = {}, timeoutMs = 10_000) {
|
|
163
|
+
return postJson(`${this.restBase}/info`, { type, ...extra }, timeoutMs)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async meta() { return (await this._infoFns()).meta() }
|
|
167
|
+
async metaAndAssetCtxs() { return (await this._infoFns()).metaAndAssetCtxs() }
|
|
168
|
+
async allMids() { return (await this._infoFns()).allMids() }
|
|
169
|
+
async l2Book(coin, grouping = null) { return (await this._infoFns()).l2Book(coin, grouping) }
|
|
170
|
+
recentTrades(coin) { return this.info('recentTrades', { coin }) }
|
|
171
|
+
async candleSnapshot(coin, interval, startTime, endTime) {
|
|
172
|
+
return (await this._infoFns()).candleSnapshot(coin, interval, startTime, endTime)
|
|
173
|
+
}
|
|
174
|
+
async clearinghouseState(user) { return (await this._infoFns()).clearinghouseState(user) }
|
|
175
|
+
async spotClearinghouseState(user) { return (await this._infoFns()).spotClearinghouseState(user) }
|
|
176
|
+
async userAbstraction(user) { return (await this._infoFns()).userAbstraction(user) }
|
|
177
|
+
async activeAssetData(user, coin) { return (await this._infoFns()).activeAssetData(user, coin) }
|
|
178
|
+
async openOrders(user) { return (await this._infoFns()).openOrders(user) }
|
|
179
|
+
async frontendOpenOrders(user) { return (await this._infoFns()).frontendOpenOrders(user) }
|
|
180
|
+
async userFills(user) { return (await this._infoFns()).userFills(user) }
|
|
181
|
+
async historicalOrders(user) { return (await this._infoFns()).historicalOrders(user) }
|
|
182
|
+
async portfolio(user) { return (await this._infoFns()).portfolio(user) }
|
|
183
|
+
|
|
184
|
+
// ── /exchange ────────────────────────────────────────────────────────
|
|
185
|
+
/**
|
|
186
|
+
* Send a signed exchange action. Paper mode never reaches here.
|
|
187
|
+
* Execution stays on our validated EIP-712 path (lib/signing.js).
|
|
188
|
+
* @param {object} action L1 action body, e.g. {type:'order', orders:[…], grouping:'na'}
|
|
189
|
+
*/
|
|
190
|
+
async exchange(action) {
|
|
191
|
+
if (!this.privateKey) throw new Error('未配置钱包私钥(插件 config.privateKey);paper 模式下单不经过 /exchange')
|
|
192
|
+
const nonce = this.nextNonce()
|
|
193
|
+
const canonical = canonicalAction(action)
|
|
194
|
+
const signature = await signL1Action(this.privateKey, canonical, null, nonce, this.network === 'mainnet')
|
|
195
|
+
return postJson(`${this.restBase}/exchange`, { action: canonical, nonce, signature }, 15_000)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
nextNonce() {
|
|
199
|
+
this.lastNonce = Math.max(Date.now(), this.lastNonce + 1)
|
|
200
|
+
return this.lastNonce
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Send an exchange action with an EXTERNALLY produced signature — the
|
|
205
|
+
* dsh-web3 path: the private key never leaves the wallet vault, this
|
|
206
|
+
* client only transports the already-signed envelope.
|
|
207
|
+
* @param {object} action L1 action body
|
|
208
|
+
* @param {{r: string, s: string, v: number}} signature wire-form signature
|
|
209
|
+
* @param {number} nonce the nonce that was hashed into the signature
|
|
210
|
+
*/
|
|
211
|
+
exchangeSigned(action, signature, nonce) {
|
|
212
|
+
if (!signature?.r || !signature?.s || !signature?.v) throw new Error('exchangeSigned 需要完整的 {r,s,v} 签名')
|
|
213
|
+
return postJson(`${this.restBase}/exchange`, { action: canonicalAction(action), nonce, signature }, 15_000)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── WebSocket upstream (SDK SubscriptionClient) ──────────────────────
|
|
217
|
+
/** Map a snapshot key to a SubscriptionClient channel; resolve its ISubscription. */
|
|
218
|
+
async _openChannel(key, args) {
|
|
219
|
+
await this._infoFns()
|
|
220
|
+
this.wsState = 'connecting'
|
|
221
|
+
const sc = this._subsClient
|
|
222
|
+
const feed = (payload) => {
|
|
223
|
+
this.snapshots.set(key, payload)
|
|
224
|
+
this.snapshotTimes.set(key, Date.now())
|
|
225
|
+
this.seqs.set(key, (this.seqs.get(key) ?? 0) + 1)
|
|
226
|
+
this.wsState = 'open'
|
|
227
|
+
}
|
|
228
|
+
switch (args?.type) {
|
|
229
|
+
case 'allMids':
|
|
230
|
+
return sc.allMids((ev) => feed(ev?.detail ?? ev))
|
|
231
|
+
case 'allDexsAssetCtxs':
|
|
232
|
+
return sc.allDexsAssetCtxs((ev) => feed(ev?.detail ?? ev))
|
|
233
|
+
case 'allDexsClearinghouseState':
|
|
234
|
+
return sc.allDexsClearinghouseState({ user: args.user }, (ev) => feed(ev?.detail ?? ev))
|
|
235
|
+
case 'l2Book':
|
|
236
|
+
return sc.l2Book({ coin: args.coin, ...(args.nSigFigs ? { nSigFigs: args.nSigFigs, ...(args.mantissa ? { mantissa: args.mantissa } : {}) } : {}) }, (ev) => feed(ev?.detail ?? ev))
|
|
237
|
+
case 'trades':
|
|
238
|
+
return sc.trades({ coin: args.coin }, (ev) => feed(ev?.detail ?? ev))
|
|
239
|
+
case 'candle':
|
|
240
|
+
return sc.candle({ coin: args.coin, interval: args.interval }, (ev) => feed(ev?.detail ?? ev))
|
|
241
|
+
case 'userFills':
|
|
242
|
+
return sc.userFills({ user: args.user }, (ev) => feed(ev?.detail ?? ev))
|
|
243
|
+
case 'userEvents':
|
|
244
|
+
return sc.userEvents({ user: args.user }, (ev) => feed(ev?.detail ?? ev))
|
|
245
|
+
default:
|
|
246
|
+
throw new Error(`unknown subscription type: ${args?.type}`)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Register interest in a WS channel; ref-counted. Returns the snapshot key.
|
|
252
|
+
* Supported: allMids | allDexsAssetCtxs | {allDexsClearinghouseState, user} | {l2Book, coin} | {trades, coin} | {candle, coin, interval}
|
|
253
|
+
* | {userFills, user} | {userEvents, user}
|
|
254
|
+
*/
|
|
255
|
+
subscribe(key, args) {
|
|
256
|
+
const row = this.subs.get(key) ?? { args, listeners: 0, channel: null, opening: false }
|
|
257
|
+
row.listeners += 1
|
|
258
|
+
this.subs.set(key, row)
|
|
259
|
+
if (!row.channel && !row.opening) {
|
|
260
|
+
row.opening = true
|
|
261
|
+
this._openChannel(key, args)
|
|
262
|
+
.then((sub) => {
|
|
263
|
+
row.channel = sub
|
|
264
|
+
row.opening = false
|
|
265
|
+
this.wsState = 'open'
|
|
266
|
+
if (row.listeners <= 0) {
|
|
267
|
+
// Interest went away while the channel was opening.
|
|
268
|
+
quietUnsubscribe(sub)
|
|
269
|
+
row.channel = null
|
|
270
|
+
this.subs.delete(key)
|
|
271
|
+
this.snapshots.delete(key)
|
|
272
|
+
this.snapshotTimes.delete(key)
|
|
273
|
+
}
|
|
274
|
+
})
|
|
275
|
+
.catch((err) => {
|
|
276
|
+
row.opening = false
|
|
277
|
+
this.lastWsError = String(err?.message ?? err)
|
|
278
|
+
if (row.listeners > 0) this._retryChannel(key)
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
return key
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
_retryChannel(key) {
|
|
285
|
+
if (this.retryTimers.has(key)) return
|
|
286
|
+
const timer = setTimeout(() => {
|
|
287
|
+
this.retryTimers.delete(key)
|
|
288
|
+
const row = this.subs.get(key)
|
|
289
|
+
if (row && row.listeners > 0 && !row.channel && !row.opening) {
|
|
290
|
+
row.opening = true
|
|
291
|
+
this._openChannel(key, row.args)
|
|
292
|
+
.then((sub) => {
|
|
293
|
+
row.opening = false
|
|
294
|
+
if (row.listeners <= 0) { quietUnsubscribe(sub); this.subs.delete(key); return }
|
|
295
|
+
row.channel = sub
|
|
296
|
+
this.wsState = 'open'
|
|
297
|
+
})
|
|
298
|
+
.catch((err) => {
|
|
299
|
+
row.opening = false
|
|
300
|
+
this.lastWsError = String(err?.message ?? err)
|
|
301
|
+
if (row.listeners > 0) this._retryChannel(key)
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
}, 3_000)
|
|
305
|
+
this.retryTimers.set(key, timer)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
unsubscribe(key) {
|
|
309
|
+
const row = this.subs.get(key)
|
|
310
|
+
if (!row) return
|
|
311
|
+
row.listeners -= 1
|
|
312
|
+
if (row.listeners <= 0) {
|
|
313
|
+
if (row.channel) {
|
|
314
|
+
quietUnsubscribe(row.channel)
|
|
315
|
+
row.channel = null
|
|
316
|
+
}
|
|
317
|
+
this.subs.delete(key)
|
|
318
|
+
this.snapshots.delete(key)
|
|
319
|
+
this.snapshotTimes.delete(key)
|
|
320
|
+
const timer = this.retryTimers.get(key)
|
|
321
|
+
if (timer) clearTimeout(timer)
|
|
322
|
+
this.retryTimers.delete(key)
|
|
323
|
+
// The upstream socket stays connected (SDK keep-alive); only channels went away.
|
|
324
|
+
if (this.subs.size === 0 && this.wsState !== 'closed') this.wsState = 'idle'
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
snapshot(key) {
|
|
329
|
+
const snap = this.snapshots.get(key)
|
|
330
|
+
const seq = this.seqs.get(key) ?? 0
|
|
331
|
+
return snap === undefined ? null : { seq, at: this.snapshotTimes.get(key) ?? 0, data: snap }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
wsStatus() {
|
|
335
|
+
const lastDataAt = Math.max(0, ...this.snapshotTimes.values())
|
|
336
|
+
const state = this.wsState === 'open' && lastDataAt > 0 && Date.now() - lastDataAt > 15_000
|
|
337
|
+
? 'stale' : this.wsState
|
|
338
|
+
return { state, lastDataAt, subs: [...this.subs.keys()], lastError: this.lastWsError }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
close() {
|
|
342
|
+
for (const timer of this.retryTimers.values()) clearTimeout(timer)
|
|
343
|
+
this.retryTimers.clear()
|
|
344
|
+
for (const [key, row] of this.subs) {
|
|
345
|
+
if (row.channel) {
|
|
346
|
+
quietUnsubscribe(row.channel)
|
|
347
|
+
row.channel = null
|
|
348
|
+
}
|
|
349
|
+
row.listeners = 0
|
|
350
|
+
}
|
|
351
|
+
this.subs.clear()
|
|
352
|
+
this.snapshots.clear()
|
|
353
|
+
this.snapshotTimes.clear()
|
|
354
|
+
try { this._wsTransport?.close?.() } catch { /* already closed */ }
|
|
355
|
+
this.wsState = 'closed'
|
|
356
|
+
this.ws = null
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Canonical snapshot key builders shared with the remote layer. */
|
|
361
|
+
export const keys = {
|
|
362
|
+
allMids: () => 'allMids',
|
|
363
|
+
allDexsAssetCtxs: () => 'allDexsAssetCtxs',
|
|
364
|
+
allDexsClearinghouseState: (user) => `allDexsClearinghouseState:${user.toLowerCase()}`,
|
|
365
|
+
l2Book: (coin, grouping = null) => grouping
|
|
366
|
+
? `l2BookGrouped:${encodeURIComponent(coin)}:${grouping.nSigFigs}:${grouping.mantissa ?? 0}`
|
|
367
|
+
: `l2Book:${coin}`,
|
|
368
|
+
trades: (coin) => `trades:${coin}`,
|
|
369
|
+
candle: (coin, interval) => `candle:${coin}:${interval}`,
|
|
370
|
+
userEvents: () => 'userEvents',
|
|
371
|
+
userFills: (user) => `userFills:${user}`,
|
|
372
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** Deterministic indicators over exchange candles; no price prediction. */
|
|
2
|
+
|
|
3
|
+
function sma(values, period) {
|
|
4
|
+
if (values.length < period) return null
|
|
5
|
+
return values.slice(-period).reduce((sum, value) => sum + value, 0) / period
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function emaSeries(values, period) {
|
|
9
|
+
if (values.length < period) return []
|
|
10
|
+
const alpha = 2 / (period + 1)
|
|
11
|
+
let value = values.slice(0, period).reduce((sum, point) => sum + point, 0) / period
|
|
12
|
+
const result = [value]
|
|
13
|
+
for (const point of values.slice(period)) {
|
|
14
|
+
value = point * alpha + value * (1 - alpha)
|
|
15
|
+
result.push(value)
|
|
16
|
+
}
|
|
17
|
+
return result
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function wilder(values, period) {
|
|
21
|
+
if (values.length < period) return null
|
|
22
|
+
let result = values.slice(0, period).reduce((sum, value) => sum + value, 0) / period
|
|
23
|
+
for (const value of values.slice(period)) result = (result * (period - 1) + value) / period
|
|
24
|
+
return result
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function swings(rows, kind) {
|
|
28
|
+
const picks = []
|
|
29
|
+
for (let i = 2; i < rows.length - 2; i += 1) {
|
|
30
|
+
const field = kind === 'low' ? 'l' : 'h'
|
|
31
|
+
const value = rows[i][field]
|
|
32
|
+
const neighbors = [rows[i - 2][field], rows[i - 1][field], rows[i + 1][field], rows[i + 2][field]]
|
|
33
|
+
if (neighbors.every((point) => kind === 'low' ? value <= point : value >= point)) picks.push({ price: value, time: rows[i].t })
|
|
34
|
+
}
|
|
35
|
+
return picks
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function closeTime(row, interval) {
|
|
39
|
+
if (Number.isFinite(Number(row.T)) && Number(row.T) >= row.t) return Number(row.T)
|
|
40
|
+
if (interval === '1M') {
|
|
41
|
+
const opened = new Date(row.t)
|
|
42
|
+
return Date.UTC(opened.getUTCFullYear(), opened.getUTCMonth() + 1, 1) - 1
|
|
43
|
+
}
|
|
44
|
+
const minutes = { '1m': 1, '3m': 3, '5m': 5, '15m': 15, '30m': 30, '1h': 60, '2h': 120,
|
|
45
|
+
'4h': 240, '8h': 480, '12h': 720, '1d': 1440, '3d': 4320, '1w': 10080 }[interval]
|
|
46
|
+
return minutes ? row.t + minutes * 60_000 - 1 : row.t
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function computeIndicators(candles, { now = Date.now(), interval } = {}) {
|
|
50
|
+
const valid = (candles ?? []).map((c) => ({ t: Number(c.t), T: c.T, o: Number(c.o), h: Number(c.h), l: Number(c.l), c: Number(c.c), v: Number(c.v) }))
|
|
51
|
+
.filter((c) => [c.t, c.o, c.h, c.l, c.c, c.v].every(Number.isFinite) && c.t > 0 && c.h >= c.l && c.c > 0)
|
|
52
|
+
.sort((a, b) => a.t - b.t)
|
|
53
|
+
const unique = [...new Map(valid.map((row) => [row.t, row])).values()]
|
|
54
|
+
const rows = unique.filter((row) => closeTime(row, interval) < now)
|
|
55
|
+
const excludedOpen = unique.length - rows.length
|
|
56
|
+
if (rows.length < 50) return { ready: false, candlesUsed: rows.length, excludedOpen, reason: '至少需要 50 根已收盘的有效 K 线' }
|
|
57
|
+
const close = rows.map((c) => c.c)
|
|
58
|
+
const volume = rows.map((c) => c.v)
|
|
59
|
+
const ema12 = emaSeries(close, 12)
|
|
60
|
+
const ema26 = emaSeries(close, 26)
|
|
61
|
+
const macdLine = ema12.slice(14).map((value, i) => value - ema26[i])
|
|
62
|
+
const signal = emaSeries(macdLine, 9)
|
|
63
|
+
const deltas = close.slice(1).map((value, i) => value - close[i])
|
|
64
|
+
const avgGain = wilder(deltas.map((value) => Math.max(value, 0)), 14)
|
|
65
|
+
const avgLoss = wilder(deltas.map((value) => Math.max(-value, 0)), 14)
|
|
66
|
+
const rsi = avgLoss === 0 ? (avgGain === 0 ? 50 : 100) : 100 - 100 / (1 + avgGain / avgLoss)
|
|
67
|
+
const trueRanges = rows.slice(1).map((c, i) => Math.max(c.h - c.l, Math.abs(c.h - rows[i].c), Math.abs(c.l - rows[i].c)))
|
|
68
|
+
const atr14 = wilder(trueRanges, 14)
|
|
69
|
+
const close20 = close.slice(-20)
|
|
70
|
+
const mean20 = sma(close, 20)
|
|
71
|
+
const deviation = Math.sqrt(close20.reduce((sum, value) => sum + (value - mean20) ** 2, 0) / 20)
|
|
72
|
+
const latest = rows.at(-1)
|
|
73
|
+
const recent = rows.slice(-100)
|
|
74
|
+
const supportFallback = rows.slice(-20).reduce((best, row) => row.l < best.l ? row : best)
|
|
75
|
+
const resistanceFallback = rows.slice(-20).reduce((best, row) => row.h > best.h ? row : best)
|
|
76
|
+
const support = swings(recent, 'low').filter((point) => point.price < latest.c).sort((a, b) => b.price - a.price)[0]
|
|
77
|
+
?? { price: supportFallback.l, time: supportFallback.t }
|
|
78
|
+
const resistance = swings(recent, 'high').filter((point) => point.price > latest.c).sort((a, b) => a.price - b.price)[0]
|
|
79
|
+
?? { price: resistanceFallback.h, time: resistanceFallback.t }
|
|
80
|
+
return {
|
|
81
|
+
ready: true, candlesUsed: rows.length, excludedOpen, observedAt: closeTime(latest, interval), candleOpenedAt: latest.t, close: latest.c,
|
|
82
|
+
sma20: mean20, ema20: emaSeries(close, 20).at(-1), ema50: emaSeries(close, 50).at(-1),
|
|
83
|
+
rsi14: rsi, macd: { line: macdLine.at(-1), signal: signal.at(-1), histogram: macdLine.at(-1) - signal.at(-1) },
|
|
84
|
+
bollinger20: { lower: mean20 - 2 * deviation, middle: mean20, upper: mean20 + 2 * deviation },
|
|
85
|
+
atr14, volumeRatio20: latest.v / (sma(volume.slice(0, -1), 20) || 1),
|
|
86
|
+
support, resistance,
|
|
87
|
+
}
|
|
88
|
+
}
|