dsh-hyperliquid 0.0.1 → 0.2.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.
@@ -0,0 +1,20 @@
1
+ /** Validate the user-editable trading limits before they reach either guard. */
2
+ export function normalizeRiskSettings(input) {
3
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('交易设置无效')
4
+ const allowed = ['allowedCoins', 'maxOrderUsd', 'maxDailyUsd', 'maxPositionUsd', 'maxLeverage']
5
+ if (Object.keys(input).some((key) => !allowed.includes(key)) || allowed.some((key) => !(key in input))) throw new Error('交易设置字段无效')
6
+ const coins = typeof input.allowedCoins === 'string' ? input.allowedCoins.split(',') : input.allowedCoins
7
+ if (!Array.isArray(coins)) throw new Error('允许交易的币对无效')
8
+ const allowedCoins = [...new Set(coins.map((coin) => String(coin).trim()).filter(Boolean))]
9
+ if (!allowedCoins.length || allowedCoins.length > 100 || allowedCoins.some((coin) => coin.length > 32 || !/^[A-Za-z0-9:_-]+$/.test(coin))) {
10
+ throw new Error('允许交易的币对无效')
11
+ }
12
+ const values = {}
13
+ for (const key of allowed.slice(1)) {
14
+ const value = Number(input[key])
15
+ if (!Number.isFinite(value) || value <= 0 || value > 1_000_000_000) throw new Error(`${key} 必须为正数`)
16
+ values[key] = value
17
+ }
18
+ if (!Number.isInteger(values.maxLeverage) || values.maxLeverage > 50) throw new Error('maxLeverage 必须为 1–50 的整数')
19
+ return { allowedCoins, ...values }
20
+ }
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,416 @@
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, unlinkSync } 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'], maLines: [],
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.riskSettingsFile = join(dataDir, 'risk-settings.json')
30
+ this.events = [] // UI event ring
31
+ this.eventSeq = 0
32
+ this.sessions = this.#readJson(this.sessionsFile, { sessions: {} })
33
+ // Existing XYZ100 sessions may contain a display shorthand that the
34
+ // exchange rejects with HTTP 500. Repair the saved binding on startup.
35
+ let migratedCoin = false
36
+ for (const row of Object.values(this.sessions.sessions ?? {})) {
37
+ if (!row || typeof row.coin !== 'string') continue
38
+ const canonical = canonicalMarketCoin(row.coin)
39
+ if (canonical !== row.coin) { row.coin = canonical; migratedCoin = true }
40
+ }
41
+ if (migratedCoin) this.#writeJson(this.sessionsFile, this.sessions)
42
+ this.selectPaperNetwork(paperNetwork)
43
+ // A corrupt execution journal is unsafe to discard: an unknown order may
44
+ // have reached the exchange. Refuse live trading until it is repaired.
45
+ this.liveOrders = existsSync(this.liveOrdersFile)
46
+ ? JSON.parse(readFileSync(this.liveOrdersFile, 'utf8'))
47
+ : {}
48
+ if (!this.liveOrders || typeof this.liveOrders !== 'object' || Array.isArray(this.liveOrders)) throw new Error('live 订单审计账本损坏')
49
+ this.liveDecisions = existsSync(this.liveDecisionsFile)
50
+ ? JSON.parse(readFileSync(this.liveDecisionsFile, 'utf8'))
51
+ : {}
52
+ if (!this.liveDecisions || typeof this.liveDecisions !== 'object' || Array.isArray(this.liveDecisions)) throw new Error('live 决策审计账本损坏')
53
+ this.chartMarksBySession = new Map()
54
+ this.workspacesBySession = new Map()
55
+ this.#migrateChartMarks()
56
+ }
57
+
58
+ selectPaperNetwork(network) {
59
+ if (network !== 'testnet' && network !== 'mainnet') throw new Error('paper 网络无效')
60
+ const file = join(this.dataDir, `paper-${network}.json`)
61
+ const source = existsSync(file) ? file : network === this.paperLegacyNetwork && existsSync(this.paperLegacyFile) ? this.paperLegacyFile : null
62
+ let paper
63
+ try {
64
+ paper = source ? JSON.parse(readFileSync(source, 'utf8'))
65
+ : { usd: PAPER_START_USD, positions: {}, leverageByCoin: {}, fills: [], orders: [] }
66
+ } catch {
67
+ throw new Error('paper 账户文件损坏,拒绝重置模拟权益')
68
+ }
69
+ // Older paper snapshots predate per-coin leverage; an absent map is a
70
+ // known migration and does not change cash, positions, or fills.
71
+ if (paper && typeof paper === 'object' && paper.leverageByCoin == null) paper.leverageByCoin = {}
72
+ if (paper && typeof paper === 'object' && paper.orders == null) paper.orders = []
73
+ if (!paper || typeof paper !== 'object' || !Number.isFinite(paper.usd) ||
74
+ !paper.positions || typeof paper.positions !== 'object' || Array.isArray(paper.positions) ||
75
+ !Array.isArray(paper.fills) || !Array.isArray(paper.orders) || !paper.leverageByCoin ||
76
+ typeof paper.leverageByCoin !== 'object' || Array.isArray(paper.leverageByCoin)) {
77
+ throw new Error('paper 账户文件损坏,拒绝重置模拟权益')
78
+ }
79
+ this.paperFile = file
80
+ this.paper = paper
81
+ }
82
+
83
+ #readJson(file, fallback) {
84
+ try {
85
+ if (!existsSync(file)) return fallback
86
+ return JSON.parse(readFileSync(file, 'utf8'))
87
+ } catch {
88
+ return fallback
89
+ }
90
+ }
91
+
92
+ #writeJson(file, value) {
93
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
94
+ const tmp = file + '.tmp'
95
+ writeFileSync(tmp, JSON.stringify(value, null, 2), { mode: 0o600 })
96
+ renameSync(tmp, file)
97
+ }
98
+
99
+ /** Hash arbitrary DSH ids: a session id can never escape its artifact directory. */
100
+ sessionDirectory(sessionId) {
101
+ if (!this.sessions.sessions[sessionId]) throw new Error(`会话 ${sessionId} 未登记为交易会话`)
102
+ return join(this.dataDir, 'sessions', createHash('sha256').update(sessionId).digest('hex'))
103
+ }
104
+
105
+ #sessionFile(sessionId, name) { return join(this.sessionDirectory(sessionId), name) }
106
+
107
+ #readArtifact(file, fallback) {
108
+ if (!existsSync(file)) return fallback
109
+ try { return JSON.parse(readFileSync(file, 'utf8')) }
110
+ catch { throw new Error(`会话文件损坏:${file}`) }
111
+ }
112
+
113
+ getRiskSettings() { return this.#readArtifact(this.riskSettingsFile, null) }
114
+
115
+ saveRiskSettings(settings) { this.#writeJson(this.riskSettingsFile, settings) }
116
+
117
+ clearRiskSettings() {
118
+ if (existsSync(this.riskSettingsFile)) unlinkSync(this.riskSettingsFile)
119
+ }
120
+
121
+ #migrateChartMarks() {
122
+ if (!existsSync(this.chartMarksFile)) return
123
+ const legacy = this.#readArtifact(this.chartMarksFile, [])
124
+ if (!Array.isArray(legacy)) throw new Error('旧图表标注文件损坏')
125
+ const grouped = new Map()
126
+ for (const mark of legacy) {
127
+ if (!mark || !this.sessions.sessions[mark.sessionId]) continue
128
+ if (!grouped.has(mark.sessionId)) grouped.set(mark.sessionId, [])
129
+ grouped.get(mark.sessionId).push(mark)
130
+ }
131
+ // Writes are idempotent if a prior migration stopped before the rename.
132
+ for (const [sessionId, marks] of grouped) {
133
+ const file = this.#sessionFile(sessionId, 'chart-marks.json')
134
+ const existing = this.#readArtifact(file, [])
135
+ if (!Array.isArray(existing)) throw new Error('会话图表标注文件损坏')
136
+ const ids = new Set(existing.map((row) => row.id))
137
+ const merged = [...existing, ...marks.filter((row) => !ids.has(row.id))]
138
+ this.#writeJson(file, merged)
139
+ }
140
+ renameSync(this.chartMarksFile, join(this.dataDir, 'chart-marks.migrated.json'))
141
+ }
142
+
143
+ getSessionWorkspace(sessionId) {
144
+ const row = this.getSession(sessionId)
145
+ if (!row) throw new Error(`会话 ${sessionId} 未登记为交易会话`)
146
+ if (!this.workspacesBySession.has(sessionId)) {
147
+ const saved = this.#readArtifact(this.#sessionFile(sessionId, 'workspace.json'), {})
148
+ if (!saved || typeof saved !== 'object' || Array.isArray(saved)) throw new Error('会话工作台配置损坏')
149
+ this.workspacesBySession.set(sessionId, { ...WORKSPACE_DEFAULTS, ...saved,
150
+ layout: { ...WORKSPACE_DEFAULTS.layout, ...saved.layout },
151
+ chartLayers: { ...WORKSPACE_DEFAULTS.chartLayers, ...saved.chartLayers },
152
+ chartRanges: { ...saved.chartRanges }, coin: row.coin })
153
+ }
154
+ return { ...this.workspacesBySession.get(sessionId), coin: row.coin }
155
+ }
156
+
157
+ updateSessionWorkspace(sessionId, patch) {
158
+ const current = this.getSessionWorkspace(sessionId)
159
+ const next = { ...current, ...patch,
160
+ layout: patch.layout ? { ...current.layout, ...patch.layout } : current.layout,
161
+ chartLayers: patch.chartLayers ? { ...current.chartLayers, ...patch.chartLayers } : current.chartLayers,
162
+ chartRanges: patch.chartRanges ? { ...current.chartRanges, ...patch.chartRanges } : current.chartRanges,
163
+ coin: this.getSession(sessionId).coin, sessionId }
164
+ if (Object.keys(next.chartRanges).length > 200) next.chartRanges = Object.fromEntries(Object.entries(next.chartRanges).slice(-200))
165
+ this.#writeJson(this.#sessionFile(sessionId, 'workspace.json'), next)
166
+ this.workspacesBySession.set(sessionId, next)
167
+ return next
168
+ }
169
+
170
+ #marks(sessionId) {
171
+ if (!this.chartMarksBySession.has(sessionId)) {
172
+ const marks = this.#readArtifact(this.#sessionFile(sessionId, 'chart-marks.json'), [])
173
+ if (!Array.isArray(marks)) throw new Error('会话图表标注文件损坏')
174
+ this.chartMarksBySession.set(sessionId, marks)
175
+ }
176
+ return this.chartMarksBySession.get(sessionId)
177
+ }
178
+
179
+ // ── trading sessions ─────────────────────────────────────────────────
180
+ listSessions() {
181
+ return Object.values(this.sessions.sessions).sort((a, b) => b.createdAt - a.createdAt)
182
+ }
183
+
184
+ registerSession(sessionId, meta = {}) {
185
+ const prev = this.sessions.sessions[sessionId]
186
+ this.sessions.sessions[sessionId] = {
187
+ sessionId,
188
+ coin: canonicalMarketCoin(meta.coin ?? prev?.coin ?? 'BTC'),
189
+ title: meta.title ?? prev?.title ?? null,
190
+ createdAt: prev?.createdAt ?? Date.now(),
191
+ updatedAt: Date.now(),
192
+ }
193
+ this.#writeJson(this.sessionsFile, this.sessions)
194
+ this.updateSessionWorkspace(sessionId, {})
195
+ return this.sessions.sessions[sessionId]
196
+ }
197
+
198
+ removeSession(sessionId) {
199
+ if (this.sessions.sessions[sessionId]) {
200
+ delete this.sessions.sessions[sessionId]
201
+ this.#writeJson(this.sessionsFile, this.sessions)
202
+ }
203
+ const path = join(this.dataDir, 'sessions', createHash('sha256').update(sessionId).digest('hex'))
204
+ rmSync(path, { recursive: true, force: true })
205
+ this.chartMarksBySession.delete(sessionId)
206
+ this.workspacesBySession.delete(sessionId)
207
+ }
208
+
209
+ bindCoin(sessionId, coin) {
210
+ const row = this.sessions.sessions[sessionId]
211
+ if (!row) throw new Error(`会话 ${sessionId} 未登记为交易会话`)
212
+ row.coin = canonicalMarketCoin(coin)
213
+ row.updatedAt = Date.now()
214
+ this.#writeJson(this.sessionsFile, this.sessions)
215
+ this.updateSessionWorkspace(sessionId, {})
216
+ return row
217
+ }
218
+
219
+ getSession(sessionId) {
220
+ return this.sessions.sessions[sessionId] ?? null
221
+ }
222
+
223
+ /**
224
+ * Drawings synced to every trading session of a coin (TradingView's "sync drawings"):
225
+ * one shared file; each drawing keeps the session that created it as `sessionId`.
226
+ */
227
+ #shared() {
228
+ if (!this.sharedMarks) {
229
+ const marks = this.#readArtifact(join(this.dataDir, 'shared-chart-marks.json'), [])
230
+ if (!Array.isArray(marks)) throw new Error('共享图表标注文件损坏')
231
+ this.sharedMarks = marks
232
+ }
233
+ return this.sharedMarks
234
+ }
235
+
236
+ #saveShared() { this.#writeJson(join(this.dataDir, 'shared-chart-marks.json'), this.#shared()) }
237
+
238
+ #saveSession(sessionId) { this.#writeJson(this.#sessionFile(sessionId, 'chart-marks.json'), this.#marks(sessionId)) }
239
+
240
+ #notify(mark, sessionId) {
241
+ this.recordEvent('chart.mark', mark.shared
242
+ ? { sessionId: null, shared: true, coin: mark.coin, interval: mark.interval, id: mark.id }
243
+ : { sessionId, coin: mark.coin, interval: mark.interval, id: mark.id })
244
+ }
245
+
246
+ listChartMarks(sessionId, coin, interval) {
247
+ const match = (mark) => mark.coin === coin && (interval == null || mark.interval === interval)
248
+ return [...this.#marks(sessionId).filter(match), ...this.#shared().filter(match)]
249
+ }
250
+
251
+ /** Every drawing that lives in a session file or the shared file (for host-side watchers). */
252
+ allChartMarks() {
253
+ const sessions = Object.keys(this.sessions?.sessions ?? {})
254
+ return [...sessions.flatMap((id) => { try { return this.#marks(id) } catch { return [] } }), ...this.#shared()]
255
+ }
256
+
257
+ addChartMark(mark) {
258
+ const related = this.listChartMarks(mark.sessionId, mark.coin, mark.interval)
259
+ if (related.length >= 30) throw new Error('当前图表最多保留 30 条标注,请先删除旧线')
260
+ const marks = mark.shared ? this.#shared() : this.#marks(mark.sessionId)
261
+ if (marks.length >= 300) throw new Error('当前会话最多保留 300 条图表标注,请先删除旧线')
262
+ const saved = { ...mark, id: randomUUID(), createdAt: Date.now() }
263
+ marks.push(saved)
264
+ if (mark.shared) this.#saveShared(); else this.#saveSession(mark.sessionId)
265
+ this.#notify(saved, mark.sessionId)
266
+ return saved
267
+ }
268
+
269
+ getChartMark(sessionId, id) {
270
+ const mark = this.#marks(sessionId).find((row) => row.id === id) ?? this.#shared().find((row) => row.id === id)
271
+ if (!mark) throw new Error('图表标注不存在或不属于当前会话')
272
+ return mark
273
+ }
274
+
275
+ /** Patch one drawing; toggling `shared` moves it between the session file and the shared file. */
276
+ updateChartMark(sessionId, id, fields) {
277
+ const own = this.#marks(sessionId), shared = this.#shared()
278
+ const inShared = !own.some((row) => row.id === id)
279
+ const list = inShared ? shared : own
280
+ const index = list.findIndex((row) => row.id === id)
281
+ if (index < 0) throw new Error('图表标注不存在或不属于当前会话')
282
+ const next = { ...list[index], ...fields, updatedAt: Date.now() }
283
+ // Cleared fields (undefined) are removed, so stored and returned drawings stay lossless JSON.
284
+ for (const [key, value] of Object.entries(next)) if (value === undefined) delete next[key]
285
+ if (!next.shared) delete next.shared
286
+ const moving = Boolean(next.shared) !== inShared
287
+ if (moving) {
288
+ list.splice(index, 1)
289
+ if (next.shared) shared.push(next); else own.push({ ...next, sessionId })
290
+ this.#saveShared(); this.#saveSession(sessionId)
291
+ // Both the old and the new owners must reload.
292
+ this.recordEvent('chart.mark', { sessionId: null, shared: true, coin: next.coin, interval: next.interval, id })
293
+ this.recordEvent('chart.mark', { sessionId, coin: next.coin, interval: next.interval, id })
294
+ return next.shared ? next : { ...next, sessionId }
295
+ }
296
+ list[index] = next
297
+ if (inShared) this.#saveShared(); else this.#saveSession(sessionId)
298
+ this.#notify(next, sessionId)
299
+ return next
300
+ }
301
+
302
+ removeChartMark(sessionId, id) {
303
+ const own = this.#marks(sessionId)
304
+ const inShared = !own.some((row) => row.id === id)
305
+ const list = inShared ? this.#shared() : own
306
+ const mark = list.find((row) => row.id === id)
307
+ if (!mark) throw new Error('图表标注不存在或不属于当前会话')
308
+ list.splice(list.indexOf(mark), 1)
309
+ if (inShared) this.#saveShared(); else this.#saveSession(sessionId)
310
+ this.#notify(mark, sessionId)
311
+ return mark
312
+ }
313
+
314
+ // ── UI event ring ────────────────────────────────────────────────────
315
+ recordEvent(kind, data = {}) {
316
+ this.eventSeq += 1
317
+ this.events.push({ seq: this.eventSeq, at: Date.now(), kind, data })
318
+ if (this.events.length > 500) this.events.splice(0, this.events.length - 500)
319
+ return this.eventSeq
320
+ }
321
+
322
+ eventsSince(seq) {
323
+ return { events: this.events.filter((e) => e.seq > Number(seq) || !Number.isFinite(Number(seq))), seq: this.eventSeq }
324
+ }
325
+
326
+ // ── paper trading ────────────────────────────────────────────────────
327
+ paperAccount() {
328
+ return JSON.parse(JSON.stringify(this.paper))
329
+ }
330
+
331
+ /**
332
+ * Fill a paper order against the given mid with a simple slippage model.
333
+ * @returns the fill record
334
+ */
335
+ paperFill({ coin, isBuy, sz, px, time = Date.now(), reason = 'order', sessionId = null }) {
336
+ const szNum = Number(sz)
337
+ const pxNum = Number(px)
338
+ if (!Number.isFinite(szNum) || szNum <= 0) throw new Error('paper: 无效数量')
339
+ if (!Number.isFinite(pxNum) || pxNum <= 0) throw new Error('paper: 无效价格')
340
+ const notional = szNum * pxNum
341
+ const pos = this.paper.positions[coin] ?? { size: 0, entryPx: pxNum, leverage: this.paper.leverageByCoin?.[coin] ?? 1 }
342
+ const signed = isBuy ? szNum : -szNum
343
+ const prevSize = pos.size
344
+ let realized = 0
345
+ if (prevSize !== 0 && Math.sign(prevSize) !== Math.sign(signed)) {
346
+ const closing = Math.min(Math.abs(prevSize), Math.abs(signed))
347
+ realized = (pxNum - pos.entryPx) * closing * Math.sign(prevSize)
348
+ }
349
+ const newSize = prevSize + signed
350
+ if (newSize === 0) delete this.paper.positions[coin]
351
+ else {
352
+ const flip = prevSize !== 0 && Math.sign(newSize) !== Math.sign(prevSize)
353
+ const adding = Math.abs(newSize) > Math.abs(prevSize)
354
+ // Opening / flipping takes the fill price; ADDING keeps the weighted
355
+ // average; a PARTIAL CLOSE leaves the remaining position's entry
356
+ // untouched (its PnL was already settled into cash as `realized`).
357
+ pos.entryPx = prevSize === 0 || flip ? pxNum
358
+ : adding ? (pos.entryPx * Math.abs(prevSize) + pxNum * Math.abs(signed)) / (Math.abs(prevSize) + Math.abs(signed))
359
+ : pos.entryPx
360
+ pos.size = newSize
361
+ this.paper.positions[coin] = pos
362
+ }
363
+ this.paper.usd += realized // realized PnL settles into cash; margin tracked conceptually
364
+ // sessionId: the trading conversation whose decision produced this fill.
365
+ const fill = { coin, isBuy, sz: szNum, px: pxNum, notional, realized, time, reason, sessionId }
366
+ this.paper.fills.unshift(fill)
367
+ if (this.paper.fills.length > 200) this.paper.fills.length = 200
368
+ this.#writeJson(this.paperFile, this.paper)
369
+ this.recordEvent('paper.fill', fill)
370
+ return fill
371
+ }
372
+
373
+ paperOrderRecord(order) {
374
+ const row = { ...order, time: Date.now(), status: 'filled' }
375
+ this.paper.orders.unshift(row)
376
+ if (this.paper.orders.length > 200) this.paper.orders.length = 200
377
+ this.#writeJson(this.paperFile, this.paper)
378
+ this.recordEvent('paper.order', row)
379
+ }
380
+
381
+ paperSetLeverage(coin, leverage) {
382
+ this.paper.leverageByCoin ??= {}
383
+ this.paper.leverageByCoin[coin] = leverage
384
+ if (this.paper.positions[coin]) this.paper.positions[coin].leverage = leverage
385
+ this.#writeJson(this.paperFile, this.paper)
386
+ this.recordEvent('paper.leverage', { coin, leverage })
387
+ }
388
+
389
+ recordLiveDecision(decisionId, decision) {
390
+ this.liveDecisions[decisionId] = { ...decision, decisionId, at: Date.now() }
391
+ const ids = Object.keys(this.liveDecisions)
392
+ if (ids.length > 1000) {
393
+ ids.sort((a, b) => this.liveDecisions[a].at - this.liveDecisions[b].at)
394
+ for (const id of ids.slice(0, ids.length - 1000)) delete this.liveDecisions[id]
395
+ }
396
+ this.#writeJson(this.liveDecisionsFile, this.liveDecisions)
397
+ this.recordEvent('live.decision', { decisionId, sessionId: decision.sessionId, coin: decision.order.coin })
398
+ return this.liveDecisions[decisionId]
399
+ }
400
+
401
+ recordLiveOrder(cloid, patch) {
402
+ const prev = this.liveOrders[cloid] ?? {}
403
+ const row = { ...prev, ...patch, cloid, updatedAt: Date.now() }
404
+ this.liveOrders[cloid] = row
405
+ const ids = Object.keys(this.liveOrders)
406
+ if (ids.length > 1000) {
407
+ ids.sort((a, b) => this.liveOrders[a].updatedAt - this.liveOrders[b].updatedAt)
408
+ for (const id of ids.slice(0, ids.length - 1000)) delete this.liveOrders[id]
409
+ }
410
+ this.#writeJson(this.liveOrdersFile, this.liveOrders)
411
+ this.recordEvent('live.order.' + row.status, { cloid, coin: row.coin, status: row.status })
412
+ return row
413
+ }
414
+
415
+ getLiveOrder(cloid) { return this.liveOrders[cloid] ?? null }
416
+ }