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/signing.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-hyperliquid — EIP-712 L1 action signing.
|
|
3
|
+
*
|
|
4
|
+
* Replicates hyperliquid-python-sdk v0.24.0 signing.py (verified against the
|
|
5
|
+
* research doc §4, which line-read the SDK source):
|
|
6
|
+
*
|
|
7
|
+
* actionHash = keccak256(msgpack(action) ‖ nonce(8B BE)
|
|
8
|
+
* ‖ [0x00] (no vault)
|
|
9
|
+
* | [0x01 ‖ vault20(lowercase)] (vault routing)
|
|
10
|
+
* ‖ [0x00 ‖ expiresAfter(8B BE)] (opt-in expiry))
|
|
11
|
+
* phantom = { source: 'a' (mainnet) | 'b' (testnet), connectionId: actionHash }
|
|
12
|
+
* digest = keccak256(0x1901 ‖ domainSeparator ‖ structHash)
|
|
13
|
+
* domain = EIP712Domain(name "Exchange", version "1", chainId 1337,
|
|
14
|
+
* verifyingContract 0x000…000)
|
|
15
|
+
* primaryType = Agent { source: string, connectionId: bytes32 }
|
|
16
|
+
*
|
|
17
|
+
* Curve math comes from @noble/curves + @noble/hashes, imported dynamically so
|
|
18
|
+
* the bundle stays dependency-free; without them live trading degrades to a
|
|
19
|
+
* clear error while paper trading and market data keep working.
|
|
20
|
+
*
|
|
21
|
+
* Wire contract notes (research §4.4): px/sz travel as decimal STRINGS with
|
|
22
|
+
* trailing zeros stripped (float_to_wire); addresses lowercase; msgpack field
|
|
23
|
+
* order matters.
|
|
24
|
+
*
|
|
25
|
+
* ✅ VALIDATED 2026-10-12 against the installed official SDK
|
|
26
|
+
* (hyperliquid-python-sdk, eth_account 0.13.7): three vectors — mainnet order,
|
|
27
|
+
* testnet cancel with vault, updateLeverage — reproduce identical r/s/v.
|
|
28
|
+
* The one real trap this uncovered: inside the EIP-712 domain separator the
|
|
29
|
+
* `verifyingContract` address encodes as a LEFT-PADDED 32-byte value, while
|
|
30
|
+
* the actionHash vault pre-image keeps the raw 20 bytes.
|
|
31
|
+
* @module lib/signing.js
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { packb } from './msgpack.js'
|
|
35
|
+
|
|
36
|
+
/** Hyperliquid hashes MessagePack with protocol field order, not caller key order. */
|
|
37
|
+
export function canonicalAction(action) {
|
|
38
|
+
if (!action || typeof action !== 'object' || Array.isArray(action)) throw new Error('exchange action 必须为对象')
|
|
39
|
+
const fields = (value, names, label) => {
|
|
40
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} 必须为对象`)
|
|
41
|
+
for (const key of Object.keys(value)) if (!names.includes(key)) throw new Error(`${label} 包含未知字段 ${key}`)
|
|
42
|
+
for (const key of names) if (!(key in value)) throw new Error(`${label} 缺少字段 ${key}`)
|
|
43
|
+
return Object.fromEntries(names.map((key) => [key, value[key]]))
|
|
44
|
+
}
|
|
45
|
+
switch (action.type) {
|
|
46
|
+
case 'order': {
|
|
47
|
+
const top = fields(action, ['type', 'orders', 'grouping'], 'order action')
|
|
48
|
+
if (!Array.isArray(top.orders) || top.orders.length === 0) throw new Error('order action 需要 orders')
|
|
49
|
+
return { type: 'order', orders: top.orders.map((order) => {
|
|
50
|
+
const names = ['a', 'b', 'p', 's', 'r', 't', ...(order && 'c' in order ? ['c'] : [])]
|
|
51
|
+
return fields(order, names, 'order')
|
|
52
|
+
}), grouping: top.grouping }
|
|
53
|
+
}
|
|
54
|
+
case 'cancel': {
|
|
55
|
+
const top = fields(action, ['type', 'cancels'], 'cancel action')
|
|
56
|
+
if (!Array.isArray(top.cancels) || top.cancels.length === 0) throw new Error('cancel action 需要 cancels')
|
|
57
|
+
return { type: 'cancel', cancels: top.cancels.map((cancel) => fields(cancel, ['a', 'o'], 'cancel')) }
|
|
58
|
+
}
|
|
59
|
+
case 'updateLeverage':
|
|
60
|
+
return fields(action, ['type', 'asset', 'isCross', 'leverage'], 'updateLeverage action')
|
|
61
|
+
default:
|
|
62
|
+
throw new Error(`不支持的 exchange action: ${String(action.type)}`)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const NULL_ADDRESS = new Uint8Array(32) // EIP-712 encodes address members left-padded to 32 bytes
|
|
67
|
+
|
|
68
|
+
let cryptoCache = null
|
|
69
|
+
/** Load @noble stacks once; memoised. */
|
|
70
|
+
async function loadCrypto() {
|
|
71
|
+
if (cryptoCache) return cryptoCache
|
|
72
|
+
let nobleHashes
|
|
73
|
+
let nobleCurves
|
|
74
|
+
try {
|
|
75
|
+
nobleHashes = await import('@noble/hashes/sha3')
|
|
76
|
+
nobleCurves = await import('@noble/curves/secp256k1')
|
|
77
|
+
} catch (err) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
'live 下单需要 @noble/curves 与 @noble/hashes(未安装)。' +
|
|
80
|
+
'请在插件运行环境安装这两个依赖,或继续使用 paper 模式。原始错误: ' + String(err?.message ?? err),
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
// @noble/hashes ≥1.8 renames keccak256 → keccak_256; accept both spellings.
|
|
84
|
+
const keccak256 = nobleHashes.keccak256 ?? nobleHashes.keccak_256
|
|
85
|
+
const { secp256k1 } = nobleCurves
|
|
86
|
+
cryptoCache = {
|
|
87
|
+
keccak256,
|
|
88
|
+
sign: (digest, priv) => secp256k1.sign(digest, priv, { prehash: false }),
|
|
89
|
+
hexToBytes: (hex) => {
|
|
90
|
+
const clean = hex.startsWith('0x') ? hex.slice(2) : hex
|
|
91
|
+
const out = new Uint8Array(clean.length / 2)
|
|
92
|
+
for (let i = 0; i < out.length; i += 1) out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16)
|
|
93
|
+
return out
|
|
94
|
+
},
|
|
95
|
+
bytesToHex: (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''),
|
|
96
|
+
hashText: (text) => keccak256(new TextEncoder().encode(text)),
|
|
97
|
+
}
|
|
98
|
+
return cryptoCache
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function concat(...arrays) {
|
|
102
|
+
const total = arrays.reduce((n, a) => n + a.length, 0)
|
|
103
|
+
const out = new Uint8Array(total)
|
|
104
|
+
let at = 0
|
|
105
|
+
for (const a of arrays) { out.set(a, at); at += a.length }
|
|
106
|
+
return out
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function u256be(value) {
|
|
110
|
+
const hex = BigInt.asUintN(256, BigInt(value)).toString(16).padStart(64, '0')
|
|
111
|
+
return cryptoCache.hexToBytes(hex)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function addressBytes(address) {
|
|
115
|
+
const clean = address.startsWith('0x') ? address.slice(2) : address
|
|
116
|
+
if (!/^[0-9a-fA-F]{40}$/.test(clean)) throw new Error(`bad address: ${address}`)
|
|
117
|
+
return cryptoCache.hexToBytes(clean)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** keccak256(msgpack(action) ‖ nonce ‖ vaultFlag ‖ [expiresAfter]) — exported for diagnostics. */
|
|
121
|
+
export async function actionHash(action, vaultAddress, nonce, expiresAfter) {
|
|
122
|
+
const c = await loadCrypto()
|
|
123
|
+
const parts = [packb(canonicalAction(action)), u256be(nonce).slice(24)] // low 8 bytes, big-endian
|
|
124
|
+
if (vaultAddress) {
|
|
125
|
+
parts.push(new Uint8Array([0x01]), addressBytes(vaultAddress)) // addressBytes yields lowercase bytes
|
|
126
|
+
} else {
|
|
127
|
+
parts.push(new Uint8Array([0x00]))
|
|
128
|
+
}
|
|
129
|
+
if (Number.isFinite(expiresAfter)) {
|
|
130
|
+
parts.push(new Uint8Array([0x00]), u256be(Math.floor(expiresAfter)).slice(24))
|
|
131
|
+
}
|
|
132
|
+
return c.keccak256(concat(...parts))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Sign one L1 action. @returns {{r: string, s: string, v: number}}
|
|
137
|
+
* r/s are 0x-prefixed 32-byte hex; v is 27|28 (eth_sign style, no EIP-155).
|
|
138
|
+
*/
|
|
139
|
+
export async function signL1Action(privateKey, action, vaultAddress, nonce, isMainnet = true) {
|
|
140
|
+
const c = await loadCrypto()
|
|
141
|
+
const hash = await actionHash(action, vaultAddress, nonce)
|
|
142
|
+
|
|
143
|
+
const domainTypehash = c.hashText('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)')
|
|
144
|
+
const agentTypehash = c.hashText('Agent(string source,bytes32 connectionId)')
|
|
145
|
+
const domainName = c.hashText('Exchange')
|
|
146
|
+
const domainVersion = c.hashText('1')
|
|
147
|
+
|
|
148
|
+
const domainSeparator = c.keccak256(concat(domainTypehash, domainName, domainVersion, u256be(1337), NULL_ADDRESS))
|
|
149
|
+
const sourceHash = c.hashText(isMainnet ? 'a' : 'b')
|
|
150
|
+
const structHash = c.keccak256(concat(agentTypehash, sourceHash, hash))
|
|
151
|
+
const digest = c.keccak256(concat(new Uint8Array([0x19, 0x01]), domainSeparator, structHash))
|
|
152
|
+
|
|
153
|
+
const priv = c.hexToBytes(privateKey)
|
|
154
|
+
if (priv.length !== 32) throw new Error('钱包私钥格式错误(需要 32 字节 hex)')
|
|
155
|
+
const sig = c.sign(digest, priv)
|
|
156
|
+
const recovery = typeof sig.recovery === 'number' ? sig.recovery : sig.v - 27
|
|
157
|
+
const rHex = c.bytesToHex(typeof sig.r === 'bigint' ? u256be(sig.r) : sig.r).padStart(64, '0').slice(-64)
|
|
158
|
+
const sHex = c.bytesToHex(typeof sig.s === 'bigint' ? u256be(sig.s) : sig.s).padStart(64, '0').slice(-64)
|
|
159
|
+
return { r: '0x' + rHex, s: '0x' + sHex, v: 27 + recovery }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Build the L1 action as a STANDARD EIP-712 typed-data payload — the shape
|
|
164
|
+
* dsh-web3's `ctx.wallet.signTypedData` (and any ethers-style signer) accepts.
|
|
165
|
+
* Digest path equivalence is guaranteed by the same actionHash + domain
|
|
166
|
+
* constants used by signL1Action; test/signing-web3.test.mjs cross-validates
|
|
167
|
+
* that ethers signs this payload to the exact r/s/v of the official vectors.
|
|
168
|
+
* @returns {{domain, types, value}} ethers-compatible typed data
|
|
169
|
+
*/
|
|
170
|
+
export async function buildL1TypedData(action, nonce, { vaultAddress = null, isMainnet = true } = {}) {
|
|
171
|
+
const c = await loadCrypto()
|
|
172
|
+
const hash = await actionHash(action, vaultAddress, nonce)
|
|
173
|
+
const connectionId = '0x' + c.bytesToHex(hash)
|
|
174
|
+
return {
|
|
175
|
+
domain: {
|
|
176
|
+
name: 'Exchange',
|
|
177
|
+
version: '1',
|
|
178
|
+
chainId: 1337,
|
|
179
|
+
verifyingContract: '0x0000000000000000000000000000000000000000',
|
|
180
|
+
},
|
|
181
|
+
types: {
|
|
182
|
+
Agent: [
|
|
183
|
+
{ name: 'source', type: 'string' },
|
|
184
|
+
{ name: 'connectionId', type: 'bytes32' },
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
value: { source: isMainnet ? 'a' : 'b', connectionId },
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Split a 65-byte hex signature (0x + 130 chars, ethers style) into the
|
|
193
|
+
* Hyperliquid wire form: r/s as 0x-prefixed 32-byte hex, v as 27|28.
|
|
194
|
+
*/
|
|
195
|
+
export function splitSignature(hex) {
|
|
196
|
+
const clean = String(hex ?? '').replace(/^0x/, '')
|
|
197
|
+
if (clean.length !== 130) throw new Error(`签名长度异常(期望 65 字节 hex):${clean.length} 字符`)
|
|
198
|
+
const r = clean.slice(0, 64)
|
|
199
|
+
const s = clean.slice(64, 128)
|
|
200
|
+
const vHex = clean.slice(128, 130)
|
|
201
|
+
const vNum = parseInt(vHex, 16)
|
|
202
|
+
const v = vNum >= 27 ? vNum : vNum + 27
|
|
203
|
+
return { r: '0x' + r, s: '0x' + s, v }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Derive the EVM address (0x…) for a private key — used for account reads. */
|
|
207
|
+
export async function addressFromPrivateKey(privateKey) {
|
|
208
|
+
const c = await loadCrypto()
|
|
209
|
+
const priv = c.hexToBytes(privateKey)
|
|
210
|
+
if (priv.length !== 32) throw new Error('钱包私钥格式错误(需要 32 字节 hex)')
|
|
211
|
+
const { secp256k1 } = await import('@noble/curves/secp256k1')
|
|
212
|
+
const pub = secp256k1.getPublicKey(priv, false) // uncompressed: 0x04 ‖ X ‖ Y
|
|
213
|
+
const hash = c.keccak256(pub.slice(1))
|
|
214
|
+
return '0x' + c.bytesToHex(hash.slice(-20))
|
|
215
|
+
}
|
package/lib/skill.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Register the packaged trading skill in DSH's native skill catalog. */
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
|
|
5
|
+
const skillUrl = new URL('../skills/hyperliquid-trading/SKILL.md', import.meta.url)
|
|
6
|
+
const source = readFileSync(skillUrl, 'utf8')
|
|
7
|
+
const content = source.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '').trim()
|
|
8
|
+
|
|
9
|
+
export function registerHyperSkill(skills) {
|
|
10
|
+
if (typeof skills?.register !== 'function') return null
|
|
11
|
+
return skills.register({
|
|
12
|
+
name: 'hyperliquid-trading',
|
|
13
|
+
description: '分析 Hyperliquid 实时行情、账户和风险,并在 DSH 交易会话中安全决策、下单、撤单和对账。',
|
|
14
|
+
whenToUse: '讨论当前交易台、Hyperliquid 市场、持仓、订单、paper 或 testnet/live 交易时使用。',
|
|
15
|
+
source: 'bundled',
|
|
16
|
+
path: fileURLToPath(skillUrl),
|
|
17
|
+
content,
|
|
18
|
+
})
|
|
19
|
+
}
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-hyperliquid — durable store: trading-session registry, paper account,
|
|
3
|
+
* and the UI event ring. One JSON file per concern under the plugin dataDir;
|
|
4
|
+
* writes are tmp+rename atomic (the dsh namespace sweep lesson).
|
|
5
|
+
* @module lib/store.js
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, rmSync } from 'node:fs'
|
|
9
|
+
import { dirname, join } from 'node:path'
|
|
10
|
+
import { randomUUID, createHash } from 'node:crypto'
|
|
11
|
+
import { canonicalMarketCoin } from './market-symbol.js'
|
|
12
|
+
|
|
13
|
+
const PAPER_START_USD = 100_000
|
|
14
|
+
const WORKSPACE_DEFAULTS = Object.freeze({
|
|
15
|
+
interval: '15m', bookGrouping: 'full', chartTab: 'chart', tapeTab: 'book', bottomTab: 'positions', indicators: ['volume'],
|
|
16
|
+
priceScale: 'normal', chartRanges: {}, chartLayers: { orders: true, fills: true, drawings: true },
|
|
17
|
+
layout: { sessionsWidth: 232, chatWidth: 480, sessionsCollapsed: false, deskCollapsed: false, chatCollapsed: false },
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
export class HyperStore {
|
|
21
|
+
constructor(dataDir, paperNetwork = 'testnet') {
|
|
22
|
+
this.dataDir = dataDir
|
|
23
|
+
this.sessionsFile = join(dataDir, 'sessions.json')
|
|
24
|
+
this.paperLegacyFile = join(dataDir, 'paper.json')
|
|
25
|
+
this.paperLegacyNetwork = paperNetwork
|
|
26
|
+
this.liveOrdersFile = join(dataDir, 'live-orders.json')
|
|
27
|
+
this.liveDecisionsFile = join(dataDir, 'live-decisions.json')
|
|
28
|
+
this.chartMarksFile = join(dataDir, 'chart-marks.json')
|
|
29
|
+
this.events = [] // UI event ring
|
|
30
|
+
this.eventSeq = 0
|
|
31
|
+
this.sessions = this.#readJson(this.sessionsFile, { sessions: {} })
|
|
32
|
+
// Existing XYZ100 sessions may contain a display shorthand that the
|
|
33
|
+
// exchange rejects with HTTP 500. Repair the saved binding on startup.
|
|
34
|
+
let migratedCoin = false
|
|
35
|
+
for (const row of Object.values(this.sessions.sessions ?? {})) {
|
|
36
|
+
if (!row || typeof row.coin !== 'string') continue
|
|
37
|
+
const canonical = canonicalMarketCoin(row.coin)
|
|
38
|
+
if (canonical !== row.coin) { row.coin = canonical; migratedCoin = true }
|
|
39
|
+
}
|
|
40
|
+
if (migratedCoin) this.#writeJson(this.sessionsFile, this.sessions)
|
|
41
|
+
this.selectPaperNetwork(paperNetwork)
|
|
42
|
+
// A corrupt execution journal is unsafe to discard: an unknown order may
|
|
43
|
+
// have reached the exchange. Refuse live trading until it is repaired.
|
|
44
|
+
this.liveOrders = existsSync(this.liveOrdersFile)
|
|
45
|
+
? JSON.parse(readFileSync(this.liveOrdersFile, 'utf8'))
|
|
46
|
+
: {}
|
|
47
|
+
if (!this.liveOrders || typeof this.liveOrders !== 'object' || Array.isArray(this.liveOrders)) throw new Error('live 订单审计账本损坏')
|
|
48
|
+
this.liveDecisions = existsSync(this.liveDecisionsFile)
|
|
49
|
+
? JSON.parse(readFileSync(this.liveDecisionsFile, 'utf8'))
|
|
50
|
+
: {}
|
|
51
|
+
if (!this.liveDecisions || typeof this.liveDecisions !== 'object' || Array.isArray(this.liveDecisions)) throw new Error('live 决策审计账本损坏')
|
|
52
|
+
this.chartMarksBySession = new Map()
|
|
53
|
+
this.workspacesBySession = new Map()
|
|
54
|
+
this.#migrateChartMarks()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
selectPaperNetwork(network) {
|
|
58
|
+
if (network !== 'testnet' && network !== 'mainnet') throw new Error('paper 网络无效')
|
|
59
|
+
const file = join(this.dataDir, `paper-${network}.json`)
|
|
60
|
+
const source = existsSync(file) ? file : network === this.paperLegacyNetwork && existsSync(this.paperLegacyFile) ? this.paperLegacyFile : null
|
|
61
|
+
let paper
|
|
62
|
+
try {
|
|
63
|
+
paper = source ? JSON.parse(readFileSync(source, 'utf8'))
|
|
64
|
+
: { usd: PAPER_START_USD, positions: {}, leverageByCoin: {}, fills: [], orders: [] }
|
|
65
|
+
} catch {
|
|
66
|
+
throw new Error('paper 账户文件损坏,拒绝重置模拟权益')
|
|
67
|
+
}
|
|
68
|
+
// Older paper snapshots predate per-coin leverage; an absent map is a
|
|
69
|
+
// known migration and does not change cash, positions, or fills.
|
|
70
|
+
if (paper && typeof paper === 'object' && paper.leverageByCoin == null) paper.leverageByCoin = {}
|
|
71
|
+
if (paper && typeof paper === 'object' && paper.orders == null) paper.orders = []
|
|
72
|
+
if (!paper || typeof paper !== 'object' || !Number.isFinite(paper.usd) ||
|
|
73
|
+
!paper.positions || typeof paper.positions !== 'object' || Array.isArray(paper.positions) ||
|
|
74
|
+
!Array.isArray(paper.fills) || !Array.isArray(paper.orders) || !paper.leverageByCoin ||
|
|
75
|
+
typeof paper.leverageByCoin !== 'object' || Array.isArray(paper.leverageByCoin)) {
|
|
76
|
+
throw new Error('paper 账户文件损坏,拒绝重置模拟权益')
|
|
77
|
+
}
|
|
78
|
+
this.paperFile = file
|
|
79
|
+
this.paper = paper
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#readJson(file, fallback) {
|
|
83
|
+
try {
|
|
84
|
+
if (!existsSync(file)) return fallback
|
|
85
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
86
|
+
} catch {
|
|
87
|
+
return fallback
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#writeJson(file, value) {
|
|
92
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
|
|
93
|
+
const tmp = file + '.tmp'
|
|
94
|
+
writeFileSync(tmp, JSON.stringify(value, null, 2), { mode: 0o600 })
|
|
95
|
+
renameSync(tmp, file)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Hash arbitrary DSH ids: a session id can never escape its artifact directory. */
|
|
99
|
+
sessionDirectory(sessionId) {
|
|
100
|
+
if (!this.sessions.sessions[sessionId]) throw new Error(`会话 ${sessionId} 未登记为交易会话`)
|
|
101
|
+
return join(this.dataDir, 'sessions', createHash('sha256').update(sessionId).digest('hex'))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#sessionFile(sessionId, name) { return join(this.sessionDirectory(sessionId), name) }
|
|
105
|
+
|
|
106
|
+
#readArtifact(file, fallback) {
|
|
107
|
+
if (!existsSync(file)) return fallback
|
|
108
|
+
try { return JSON.parse(readFileSync(file, 'utf8')) }
|
|
109
|
+
catch { throw new Error(`会话文件损坏:${file}`) }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#migrateChartMarks() {
|
|
113
|
+
if (!existsSync(this.chartMarksFile)) return
|
|
114
|
+
const legacy = this.#readArtifact(this.chartMarksFile, [])
|
|
115
|
+
if (!Array.isArray(legacy)) throw new Error('旧图表标注文件损坏')
|
|
116
|
+
const grouped = new Map()
|
|
117
|
+
for (const mark of legacy) {
|
|
118
|
+
if (!mark || !this.sessions.sessions[mark.sessionId]) continue
|
|
119
|
+
if (!grouped.has(mark.sessionId)) grouped.set(mark.sessionId, [])
|
|
120
|
+
grouped.get(mark.sessionId).push(mark)
|
|
121
|
+
}
|
|
122
|
+
// Writes are idempotent if a prior migration stopped before the rename.
|
|
123
|
+
for (const [sessionId, marks] of grouped) {
|
|
124
|
+
const file = this.#sessionFile(sessionId, 'chart-marks.json')
|
|
125
|
+
const existing = this.#readArtifact(file, [])
|
|
126
|
+
if (!Array.isArray(existing)) throw new Error('会话图表标注文件损坏')
|
|
127
|
+
const ids = new Set(existing.map((row) => row.id))
|
|
128
|
+
const merged = [...existing, ...marks.filter((row) => !ids.has(row.id))]
|
|
129
|
+
this.#writeJson(file, merged)
|
|
130
|
+
}
|
|
131
|
+
renameSync(this.chartMarksFile, join(this.dataDir, 'chart-marks.migrated.json'))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
getSessionWorkspace(sessionId) {
|
|
135
|
+
const row = this.getSession(sessionId)
|
|
136
|
+
if (!row) throw new Error(`会话 ${sessionId} 未登记为交易会话`)
|
|
137
|
+
if (!this.workspacesBySession.has(sessionId)) {
|
|
138
|
+
const saved = this.#readArtifact(this.#sessionFile(sessionId, 'workspace.json'), {})
|
|
139
|
+
if (!saved || typeof saved !== 'object' || Array.isArray(saved)) throw new Error('会话工作台配置损坏')
|
|
140
|
+
this.workspacesBySession.set(sessionId, { ...WORKSPACE_DEFAULTS, ...saved,
|
|
141
|
+
layout: { ...WORKSPACE_DEFAULTS.layout, ...saved.layout },
|
|
142
|
+
chartLayers: { ...WORKSPACE_DEFAULTS.chartLayers, ...saved.chartLayers },
|
|
143
|
+
chartRanges: { ...saved.chartRanges }, coin: row.coin })
|
|
144
|
+
}
|
|
145
|
+
return { ...this.workspacesBySession.get(sessionId), coin: row.coin }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
updateSessionWorkspace(sessionId, patch) {
|
|
149
|
+
const current = this.getSessionWorkspace(sessionId)
|
|
150
|
+
const next = { ...current, ...patch,
|
|
151
|
+
layout: patch.layout ? { ...current.layout, ...patch.layout } : current.layout,
|
|
152
|
+
chartLayers: patch.chartLayers ? { ...current.chartLayers, ...patch.chartLayers } : current.chartLayers,
|
|
153
|
+
chartRanges: patch.chartRanges ? { ...current.chartRanges, ...patch.chartRanges } : current.chartRanges,
|
|
154
|
+
coin: this.getSession(sessionId).coin, sessionId }
|
|
155
|
+
if (Object.keys(next.chartRanges).length > 200) next.chartRanges = Object.fromEntries(Object.entries(next.chartRanges).slice(-200))
|
|
156
|
+
this.#writeJson(this.#sessionFile(sessionId, 'workspace.json'), next)
|
|
157
|
+
this.workspacesBySession.set(sessionId, next)
|
|
158
|
+
return next
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
#marks(sessionId) {
|
|
162
|
+
if (!this.chartMarksBySession.has(sessionId)) {
|
|
163
|
+
const marks = this.#readArtifact(this.#sessionFile(sessionId, 'chart-marks.json'), [])
|
|
164
|
+
if (!Array.isArray(marks)) throw new Error('会话图表标注文件损坏')
|
|
165
|
+
this.chartMarksBySession.set(sessionId, marks)
|
|
166
|
+
}
|
|
167
|
+
return this.chartMarksBySession.get(sessionId)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── trading sessions ─────────────────────────────────────────────────
|
|
171
|
+
listSessions() {
|
|
172
|
+
return Object.values(this.sessions.sessions).sort((a, b) => b.createdAt - a.createdAt)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
registerSession(sessionId, meta = {}) {
|
|
176
|
+
const prev = this.sessions.sessions[sessionId]
|
|
177
|
+
this.sessions.sessions[sessionId] = {
|
|
178
|
+
sessionId,
|
|
179
|
+
coin: canonicalMarketCoin(meta.coin ?? prev?.coin ?? 'BTC'),
|
|
180
|
+
title: meta.title ?? prev?.title ?? null,
|
|
181
|
+
createdAt: prev?.createdAt ?? Date.now(),
|
|
182
|
+
updatedAt: Date.now(),
|
|
183
|
+
}
|
|
184
|
+
this.#writeJson(this.sessionsFile, this.sessions)
|
|
185
|
+
this.updateSessionWorkspace(sessionId, {})
|
|
186
|
+
return this.sessions.sessions[sessionId]
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
removeSession(sessionId) {
|
|
190
|
+
if (this.sessions.sessions[sessionId]) {
|
|
191
|
+
delete this.sessions.sessions[sessionId]
|
|
192
|
+
this.#writeJson(this.sessionsFile, this.sessions)
|
|
193
|
+
}
|
|
194
|
+
const path = join(this.dataDir, 'sessions', createHash('sha256').update(sessionId).digest('hex'))
|
|
195
|
+
rmSync(path, { recursive: true, force: true })
|
|
196
|
+
this.chartMarksBySession.delete(sessionId)
|
|
197
|
+
this.workspacesBySession.delete(sessionId)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
bindCoin(sessionId, coin) {
|
|
201
|
+
const row = this.sessions.sessions[sessionId]
|
|
202
|
+
if (!row) throw new Error(`会话 ${sessionId} 未登记为交易会话`)
|
|
203
|
+
row.coin = canonicalMarketCoin(coin)
|
|
204
|
+
row.updatedAt = Date.now()
|
|
205
|
+
this.#writeJson(this.sessionsFile, this.sessions)
|
|
206
|
+
this.updateSessionWorkspace(sessionId, {})
|
|
207
|
+
return row
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
getSession(sessionId) {
|
|
211
|
+
return this.sessions.sessions[sessionId] ?? null
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
listChartMarks(sessionId, coin, interval) {
|
|
215
|
+
return this.#marks(sessionId).filter((mark) => mark.coin === coin && mark.interval === interval)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
addChartMark(mark) {
|
|
219
|
+
const related = this.listChartMarks(mark.sessionId, mark.coin, mark.interval)
|
|
220
|
+
if (related.length >= 30) throw new Error('当前图表最多保留 30 条标注,请先删除旧线')
|
|
221
|
+
const marks = this.#marks(mark.sessionId)
|
|
222
|
+
if (marks.length >= 300) throw new Error('当前会话最多保留 300 条图表标注,请先删除旧线')
|
|
223
|
+
const saved = { ...mark, id: randomUUID(), createdAt: Date.now() }
|
|
224
|
+
marks.push(saved)
|
|
225
|
+
this.#writeJson(this.#sessionFile(mark.sessionId, 'chart-marks.json'), marks)
|
|
226
|
+
this.recordEvent('chart.mark', { sessionId: mark.sessionId, coin: mark.coin, interval: mark.interval, id: saved.id })
|
|
227
|
+
return saved
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
removeChartMark(sessionId, id) {
|
|
231
|
+
const marks = this.#marks(sessionId)
|
|
232
|
+
const mark = marks.find((row) => row.id === id)
|
|
233
|
+
if (!mark) throw new Error('图表标注不存在或不属于当前会话')
|
|
234
|
+
this.chartMarksBySession.set(sessionId, marks.filter((row) => row !== mark))
|
|
235
|
+
this.#writeJson(this.#sessionFile(sessionId, 'chart-marks.json'), this.#marks(sessionId))
|
|
236
|
+
this.recordEvent('chart.mark', { sessionId, coin: mark.coin, interval: mark.interval, id })
|
|
237
|
+
return mark
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── UI event ring ────────────────────────────────────────────────────
|
|
241
|
+
recordEvent(kind, data = {}) {
|
|
242
|
+
this.eventSeq += 1
|
|
243
|
+
this.events.push({ seq: this.eventSeq, at: Date.now(), kind, data })
|
|
244
|
+
if (this.events.length > 500) this.events.splice(0, this.events.length - 500)
|
|
245
|
+
return this.eventSeq
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
eventsSince(seq) {
|
|
249
|
+
return { events: this.events.filter((e) => e.seq > Number(seq) || !Number.isFinite(Number(seq))), seq: this.eventSeq }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── paper trading ────────────────────────────────────────────────────
|
|
253
|
+
paperAccount() {
|
|
254
|
+
return JSON.parse(JSON.stringify(this.paper))
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Fill a paper order against the given mid with a simple slippage model.
|
|
259
|
+
* @returns the fill record
|
|
260
|
+
*/
|
|
261
|
+
paperFill({ coin, isBuy, sz, px, time = Date.now(), reason = 'order' }) {
|
|
262
|
+
const szNum = Number(sz)
|
|
263
|
+
const pxNum = Number(px)
|
|
264
|
+
if (!Number.isFinite(szNum) || szNum <= 0) throw new Error('paper: 无效数量')
|
|
265
|
+
if (!Number.isFinite(pxNum) || pxNum <= 0) throw new Error('paper: 无效价格')
|
|
266
|
+
const notional = szNum * pxNum
|
|
267
|
+
const pos = this.paper.positions[coin] ?? { size: 0, entryPx: pxNum, leverage: this.paper.leverageByCoin?.[coin] ?? 1 }
|
|
268
|
+
const signed = isBuy ? szNum : -szNum
|
|
269
|
+
const prevSize = pos.size
|
|
270
|
+
let realized = 0
|
|
271
|
+
if (prevSize !== 0 && Math.sign(prevSize) !== Math.sign(signed)) {
|
|
272
|
+
const closing = Math.min(Math.abs(prevSize), Math.abs(signed))
|
|
273
|
+
realized = (pxNum - pos.entryPx) * closing * Math.sign(prevSize)
|
|
274
|
+
}
|
|
275
|
+
const newSize = prevSize + signed
|
|
276
|
+
if (newSize === 0) delete this.paper.positions[coin]
|
|
277
|
+
else {
|
|
278
|
+
const flip = prevSize !== 0 && Math.sign(newSize) !== Math.sign(prevSize)
|
|
279
|
+
const adding = Math.abs(newSize) > Math.abs(prevSize)
|
|
280
|
+
// Opening / flipping takes the fill price; ADDING keeps the weighted
|
|
281
|
+
// average; a PARTIAL CLOSE leaves the remaining position's entry
|
|
282
|
+
// untouched (its PnL was already settled into cash as `realized`).
|
|
283
|
+
pos.entryPx = prevSize === 0 || flip ? pxNum
|
|
284
|
+
: adding ? (pos.entryPx * Math.abs(prevSize) + pxNum * Math.abs(signed)) / (Math.abs(prevSize) + Math.abs(signed))
|
|
285
|
+
: pos.entryPx
|
|
286
|
+
pos.size = newSize
|
|
287
|
+
this.paper.positions[coin] = pos
|
|
288
|
+
}
|
|
289
|
+
this.paper.usd += realized // realized PnL settles into cash; margin tracked conceptually
|
|
290
|
+
const fill = { coin, isBuy, sz: szNum, px: pxNum, notional, realized, time, reason }
|
|
291
|
+
this.paper.fills.unshift(fill)
|
|
292
|
+
if (this.paper.fills.length > 200) this.paper.fills.length = 200
|
|
293
|
+
this.#writeJson(this.paperFile, this.paper)
|
|
294
|
+
this.recordEvent('paper.fill', fill)
|
|
295
|
+
return fill
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
paperOrderRecord(order) {
|
|
299
|
+
const row = { ...order, time: Date.now(), status: 'filled' }
|
|
300
|
+
this.paper.orders.unshift(row)
|
|
301
|
+
if (this.paper.orders.length > 200) this.paper.orders.length = 200
|
|
302
|
+
this.#writeJson(this.paperFile, this.paper)
|
|
303
|
+
this.recordEvent('paper.order', row)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
paperSetLeverage(coin, leverage) {
|
|
307
|
+
this.paper.leverageByCoin ??= {}
|
|
308
|
+
this.paper.leverageByCoin[coin] = leverage
|
|
309
|
+
if (this.paper.positions[coin]) this.paper.positions[coin].leverage = leverage
|
|
310
|
+
this.#writeJson(this.paperFile, this.paper)
|
|
311
|
+
this.recordEvent('paper.leverage', { coin, leverage })
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
recordLiveDecision(decisionId, decision) {
|
|
315
|
+
this.liveDecisions[decisionId] = { ...decision, decisionId, at: Date.now() }
|
|
316
|
+
const ids = Object.keys(this.liveDecisions)
|
|
317
|
+
if (ids.length > 1000) {
|
|
318
|
+
ids.sort((a, b) => this.liveDecisions[a].at - this.liveDecisions[b].at)
|
|
319
|
+
for (const id of ids.slice(0, ids.length - 1000)) delete this.liveDecisions[id]
|
|
320
|
+
}
|
|
321
|
+
this.#writeJson(this.liveDecisionsFile, this.liveDecisions)
|
|
322
|
+
this.recordEvent('live.decision', { decisionId, sessionId: decision.sessionId, coin: decision.order.coin })
|
|
323
|
+
return this.liveDecisions[decisionId]
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
recordLiveOrder(cloid, patch) {
|
|
327
|
+
const prev = this.liveOrders[cloid] ?? {}
|
|
328
|
+
const row = { ...prev, ...patch, cloid, updatedAt: Date.now() }
|
|
329
|
+
this.liveOrders[cloid] = row
|
|
330
|
+
const ids = Object.keys(this.liveOrders)
|
|
331
|
+
if (ids.length > 1000) {
|
|
332
|
+
ids.sort((a, b) => this.liveOrders[a].updatedAt - this.liveOrders[b].updatedAt)
|
|
333
|
+
for (const id of ids.slice(0, ids.length - 1000)) delete this.liveOrders[id]
|
|
334
|
+
}
|
|
335
|
+
this.#writeJson(this.liveOrdersFile, this.liveOrders)
|
|
336
|
+
this.recordEvent('live.order.' + row.status, { cloid, coin: row.coin, status: row.status })
|
|
337
|
+
return row
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
getLiveOrder(cloid) { return this.liveOrders[cloid] ?? null }
|
|
341
|
+
}
|