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/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
+ }