dsh-hyperliquid 0.1.0 → 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.
package/lib/remote.js CHANGED
@@ -13,6 +13,9 @@ import { canonicalMarketCoin } from './market-symbol.js'
13
13
  import { HyperStore } from './store.js'
14
14
  import { addressFromPrivateKey, buildL1TypedData, splitSignature } from './signing.js'
15
15
  import { TradingGuard } from './trading-guard.js'
16
+ import { normalizeMaLines } from './moving-average.js'
17
+ import { ALERT_KINDS, ICON_NAMES, TEXT_KINDS, drawingGeometry, drawingStyle, isIconLabel, linePriceAt } from './chart-drawings.js'
18
+ import { normalizeRiskSettings } from './risk-settings.js'
16
19
 
17
20
  const TOOL_CALL = Symbol('verified DSH tool call')
18
21
 
@@ -81,6 +84,7 @@ const ACCOUNT_MODE_TTL_MS = 30_000
81
84
  const TRADE_CAPACITY_TTL_MS = 15_000
82
85
  const SUB_SWEEP_MS = 30_000
83
86
  const SUB_IDLE_MS = 120_000
87
+ const ALERT_CHECK_MS = 2_000
84
88
  const BOOK_GROUPINGS = {
85
89
  full: null,
86
90
  '5': { nSigFigs: 5 },
@@ -121,6 +125,10 @@ export class HyperRemote extends TypertRemoteService {
121
125
  if (this.cfg.tradingAddress && !/^0x[0-9a-fA-F]{40}$/.test(this.cfg.tradingAddress)) throw new Error('tradingAddress 必须为 20 字节 EVM 地址')
122
126
  this.client = new HyperliquidClient({ network: this.cfg.network, privateKey: this.cfg.privateKey })
123
127
  this.store = new HyperStore(this.cfg.dataDir, this.cfg.network)
128
+ this.baseRiskSettings = Object.fromEntries(['allowedCoins', 'maxOrderUsd', 'maxDailyUsd', 'maxPositionUsd', 'maxLeverage'].map((key) => [key, this.cfg[key]]))
129
+ const savedRiskSettings = this.store.getRiskSettings()
130
+ this.riskSettingsOverride = savedRiskSettings != null
131
+ if (savedRiskSettings) Object.assign(this.cfg, normalizeRiskSettings(savedRiskSettings))
124
132
  this.guard = new TradingGuard(this.cfg.dataDir, { ...this.cfg, dataDir: this.cfg.dataDir })
125
133
  this.paperGuard = new TradingGuard(this.cfg.dataDir, { ...this.cfg, dataDir: this.cfg.dataDir }, { budgetName: this.cfg.network === 'mainnet' ? 'paper-budget-mainnet.json' : 'paper-budget.json' })
126
134
  this.marketsCache = null
@@ -149,6 +157,10 @@ export class HyperRemote extends TypertRemoteService {
149
157
  }
150
158
  }
151
159
  this.sweepTimer = setInterval(() => this.sweepSubs(), SUB_SWEEP_MS)
160
+ this.sweepTimer.unref?.()
161
+ this.alertSides = new Map() // drawing id → last side of the mid price (-1 below, 1 above)
162
+ this.alertTimer = setInterval(() => { try { this.checkDrawingAlerts() } catch { /* next tick retries */ } }, ALERT_CHECK_MS)
163
+ this.alertTimer.unref?.()
152
164
  }
153
165
 
154
166
  sweepSubs() {
@@ -443,7 +455,7 @@ export class HyperRemote extends TypertRemoteService {
443
455
  const sessionId = String(request?.sessionId ?? '')
444
456
  if (!this.store.getSession(sessionId)) return { marks: [] }
445
457
  const coin = requireCoin(request)
446
- const interval = requireInterval(request?.interval ?? this.desk.interval)
458
+ const interval = request?.allIntervals === true ? null : requireInterval(request?.interval ?? this.desk.interval)
447
459
  return { marks: this.store.listChartMarks(sessionId, coin, interval) }
448
460
  }
449
461
 
@@ -451,30 +463,86 @@ export class HyperRemote extends TypertRemoteService {
451
463
  this.requireTradingSession(sessionId)
452
464
  const coin = requireCoin(request)
453
465
  const interval = requireInterval(request?.interval ?? this.desk.interval)
454
- const kind = request?.kind === 'trend' ? 'trend' : 'level'
466
+ const kind = request?.kind ?? 'level'
455
467
  const role = ['support', 'resistance', 'neutral'].includes(request?.role) ? request.role : 'neutral'
456
468
  const label = String(request?.label ?? '').trim()
457
- if (!label || label.length > 60) throw new Error('图表标注需要 1-60 字标签')
458
- const mark = { sessionId, coin, interval, kind, role, label, source }
459
- if (kind === 'level') {
460
- const price = Number(request?.price)
461
- if (!Number.isFinite(price) || price <= 0) throw new Error('水平线 price 必须为正数')
462
- mark.price = price
463
- } else {
464
- const fromTime = Number(request?.fromTime)
465
- const toTime = Number(request?.toTime)
466
- const fromPrice = Number(request?.fromPrice)
467
- const toPrice = Number(request?.toPrice)
468
- const oldest = Date.now() - 90 * 86_400_000
469
- const latest = Date.now() + 86_400_000
470
- if (!(fromTime >= oldest && toTime > fromTime && toTime <= latest && fromPrice > 0 && toPrice > 0 && Number.isFinite(fromPrice) && Number.isFinite(toPrice))) {
471
- throw new Error('趋势线需要最近 90 天内递增的毫秒时间戳及正数价格')
472
- }
473
- Object.assign(mark, { fromTime, toTime, fromPrice, toPrice })
474
- }
469
+ const maxLabel = TEXT_KINDS.includes(kind) ? 200 : 60
470
+ if (!label || label.length > maxLabel) throw new Error(`图表标注需要 1-${maxLabel} 字${TEXT_KINDS.includes(kind) ? '文本' : '标签'}`)
471
+ if (kind === 'icon' && !isIconLabel(label)) throw new Error(`图标 label 必须为 ${ICON_NAMES.join('/')} 或贴图库中的 emoji:<贴纸>`)
472
+ const mark = { sessionId, coin, interval, kind, role, label, source, ...drawingGeometry(kind, request), ...drawingStyle({ ...request, kind }) }
475
473
  return this.store.addChartMark(mark)
476
474
  }
477
475
 
476
+ /** Replace a session chart's MA lines (agent path); the desk reloads on the `chart.ma` event. */
477
+ setSessionMaLines(sessionId, lines) {
478
+ this.requireTradingSession(sessionId)
479
+ const maLines = normalizeMaLines(lines)
480
+ this.store.updateSessionWorkspace(sessionId, { maLines })
481
+ this.store.recordEvent('chart.ma', { sessionId, ids: maLines.map((line) => line.id) })
482
+ return maLines
483
+ }
484
+
485
+ /** Move, reshape, restyle or relabel one drawing; geometry is re-validated as a whole. */
486
+ editChartMark(id, patch, sessionId) {
487
+ this.requireTradingSession(sessionId)
488
+ const current = this.store.getChartMark(sessionId, String(id ?? ''))
489
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new Error('画线修改无效')
490
+ const merged = { ...current, ...patch }
491
+ const geometryKeys = ['price', 'fromTime', 'fromPrice', 'toTime', 'toPrice', 'points']
492
+ const fields = { ...drawingStyle({ ...patch, kind: current.kind }) }
493
+ if (patch?.alert === true) fields.alertTriggeredAt = undefined
494
+ // null clears a style back to its default (undo of a first restyle relies on this).
495
+ for (const key of ['color', 'lineWidth', 'lineStyle']) if (key in patch && patch[key] === null) fields[key] = undefined
496
+ if (geometryKeys.some((key) => key in patch)) Object.assign(fields, drawingGeometry(current.kind, merged))
497
+ if ('label' in patch) {
498
+ const label = String(patch.label ?? '').trim()
499
+ const maxLabel = TEXT_KINDS.includes(current.kind) ? 200 : 60
500
+ if (!label || label.length > maxLabel) throw new Error(`图表标注需要 1-${maxLabel} 字`)
501
+ fields.label = label
502
+ }
503
+ if (current.locked && !('locked' in patch) && Object.keys(fields).length) throw new Error('该画线已锁定,先解锁再修改')
504
+ return this.store.updateChartMark(sessionId, current.id, fields)
505
+ }
506
+
507
+ async updateChartMark(request) {
508
+ return this.editChartMark(request?.id, request?.patch, String(request?.sessionId ?? ''))
509
+ }
510
+
511
+ /**
512
+ * Drawing alerts: when the mid price crosses an alert-enabled line it fires once (the alert
513
+ * switches off, the time is recorded) and the desk gets a `chart.alert` event.
514
+ * @param mids - coin → mid (defaults to the live allMids snapshot).
515
+ */
516
+ checkDrawingAlerts(now = Date.now(), mids = null) {
517
+ const watched = this.store.allChartMarks?.().filter((mark) => mark.alert && ALERT_KINDS.includes(mark.kind)) ?? []
518
+ for (const id of [...this.alertSides.keys()]) if (!watched.some((mark) => mark.id === id)) this.alertSides.delete(id)
519
+ if (!watched.length) return []
520
+ let book = mids
521
+ if (!book) {
522
+ const key = this.touch(keys.allMids())
523
+ const snap = this.client.snapshot(key)
524
+ if (!snap || now - snap.at > 10_000) return []
525
+ book = snap.data?.mids ?? snap.data
526
+ }
527
+ const fired = []
528
+ for (const mark of watched) {
529
+ const mid = Number(book?.[mark.coin])
530
+ const line = linePriceAt(mark, now)
531
+ if (!Number.isFinite(mid) || line == null || !Number.isFinite(line)) continue
532
+ const side = mid > line ? 1 : mid < line ? -1 : 0
533
+ const before = this.alertSides.get(mark.id)
534
+ if (side !== 0) this.alertSides.set(mark.id, side)
535
+ if (before == null || side === 0 || side === before) continue
536
+ this.alertSides.delete(mark.id)
537
+ const saved = this.store.updateChartMark(mark.sessionId, mark.id, { alert: false, alertTriggeredAt: now })
538
+ const event = { sessionId: mark.shared ? null : mark.sessionId, shared: Boolean(mark.shared), coin: mark.coin, id: mark.id, label: mark.label, kind: mark.kind,
539
+ price: mid, line, direction: side > 0 ? 'up' : 'down', at: now }
540
+ this.store.recordEvent('chart.alert', event)
541
+ fired.push({ ...event, mark: saved })
542
+ }
543
+ return fired
544
+ }
545
+
478
546
  removeChartMark(id, sessionId) {
479
547
  this.requireTradingSession(sessionId)
480
548
  return this.store.removeChartMark(sessionId, String(id ?? ''))
@@ -513,13 +581,14 @@ export class HyperRemote extends TypertRemoteService {
513
581
  if (!Array.isArray(input.indicators) || input.indicators.length > allowed.length || input.indicators.some((id) => !allowed.includes(id))) throw new Error('图表指标无效')
514
582
  patch.indicators = [...new Set(input.indicators)]
515
583
  }
584
+ if ('maLines' in input) patch.maLines = normalizeMaLines(input.maLines)
516
585
  if ('priceScale' in input) {
517
586
  if (!['normal', 'log', 'percentage'].includes(input.priceScale)) throw new Error('价格刻度无效')
518
587
  patch.priceScale = input.priceScale
519
588
  }
520
589
  if ('chartLayers' in input) {
521
590
  const layers = input.chartLayers
522
- if (!layers || typeof layers !== 'object' || Array.isArray(layers) || Object.keys(layers).some((key) => !['orders', 'fills', 'drawings'].includes(key)) || Object.values(layers).some((value) => typeof value !== 'boolean')) throw new Error('图表图层无效')
591
+ if (!layers || typeof layers !== 'object' || Array.isArray(layers) || Object.keys(layers).some((key) => !['orders', 'fills', 'drawings', 'reasons'].includes(key)) || Object.values(layers).some((value) => typeof value !== 'boolean')) throw new Error('图表图层无效')
523
592
  patch.chartLayers = layers
524
593
  }
525
594
  if ('layout' in input) {
@@ -616,6 +685,31 @@ export class HyperRemote extends TypertRemoteService {
616
685
  return { mode }
617
686
  }
618
687
 
688
+ /** Persist limits as one atomic override and update both live/paper guards. */
689
+ async updateRiskSettings(request) {
690
+ if (this.activeTradeOps > 0) throw new Error('交易操作仍在进行,暂不能修改限额')
691
+ const limits = normalizeRiskSettings(request?.limits)
692
+ this.store.saveRiskSettings(limits)
693
+ this.applyRiskSettings(limits, true)
694
+ return { riskLimits: { ...limits, usedTodayUsd: this.usedTodayUsd() } }
695
+ }
696
+
697
+ async resetRiskSettings(request) {
698
+ if (this.activeTradeOps > 0) throw new Error('交易操作仍在进行,暂不能修改限额')
699
+ this.store.clearRiskSettings()
700
+ this.applyRiskSettings(this.baseRiskSettings, false)
701
+ return { riskLimits: { ...this.baseRiskSettings, usedTodayUsd: this.usedTodayUsd() } }
702
+ }
703
+
704
+ applyRiskSettings(limits, override) {
705
+ Object.assign(this.cfg, limits)
706
+ Object.assign(this.guard.limits, limits)
707
+ Object.assign(this.paperGuard.limits, limits)
708
+ this.guard.plans.clear()
709
+ this.riskSettingsOverride = override
710
+ this.store.recordEvent('risk.changed')
711
+ }
712
+
619
713
  /** Today's (UTC) notional already counted against maxDailyUsd for the active mode. */
620
714
  usedTodayUsd() {
621
715
  const guard = this.cfg.tradingMode === 'paper' ? this.paperGuard : this.guard
@@ -632,6 +726,7 @@ export class HyperRemote extends TypertRemoteService {
632
726
  tradingMode: this.cfg.tradingMode,
633
727
  signer: this.cfg.signer,
634
728
  autonomousLive: this.cfg.autonomousLive,
729
+ riskSettingsSource: this.riskSettingsOverride ? 'desk' : 'plugin',
635
730
  riskLimits: { allowedCoins: this.cfg.allowedCoins, maxOrderUsd: this.cfg.maxOrderUsd, maxDailyUsd: this.cfg.maxDailyUsd, maxPositionUsd: this.cfg.maxPositionUsd, maxLeverage: this.cfg.maxLeverage,
636
731
  // Day budget of the ledger that applies to the current mode (paper and live are separate).
637
732
  usedTodayUsd: this.usedTodayUsd() },
@@ -830,7 +925,7 @@ export class HyperRemote extends TypertRemoteService {
830
925
  }
831
926
 
832
927
  /** Explicitly loaded history: these weighted API calls must not join the 3s account poll. */
833
- async tradeHistory() {
928
+ async tradeHistory(request) {
834
929
  if (this.cfg.tradingMode === 'paper') {
835
930
  const paper = this.store.paperAccount()
836
931
  return { mode: 'paper', orders: paper.orders.slice(0, 200), fills: paper.fills.slice(0, 200) }
@@ -847,12 +942,13 @@ export class HyperRemote extends TypertRemoteService {
847
942
  const order = entry.order ?? entry
848
943
  const local = localByCloid[order.cloid]
849
944
  return { ...order, status: entry.status ?? local?.status ?? 'unknown', statusTimestamp: entry.statusTimestamp,
850
- reason: local?.thesis ?? (local?.decisionId ? decisions[local.decisionId]?.thesis ?? null : null) }
945
+ reason: local?.thesis ?? (local?.decisionId ? decisions[local.decisionId]?.thesis ?? null : null),
946
+ sessionId: local?.sessionId ?? (local?.decisionId ? decisions[local.decisionId]?.sessionId ?? null : null) }
851
947
  }), fills: fills.slice(0, 2000) }
852
948
  }
853
949
 
854
950
  /** Hyperliquid portfolio samples are display data, not an accounting ledger. */
855
- async portfolio() {
951
+ async portfolio(request) {
856
952
  if (this.cfg.tradingMode === 'paper') {
857
953
  const account = await this.account({})
858
954
  const fills = this.store.paperAccount().fills
@@ -1011,9 +1107,11 @@ export class HyperRemote extends TypertRemoteService {
1011
1107
  if (!sessionId || !this.store.getSession(sessionId)) throw new Error('交易操作必须来自已登记的交易会话')
1012
1108
  }
1013
1109
 
1014
- async authorizeToolAction(reason, exec) {
1110
+ async authorizeToolAction(reason, exec, english = reason) {
1015
1111
  if (!exec?.agent) throw new Error('live 签名仅允许从 DSH agent 工具调用')
1016
- const outcome = await this.approvalGateway().request({ toolName: 'dsh-hyperliquid', callId: exec.callId, agent: exec.agent, signal: exec.signal, reason })
1112
+ // reason stays the audit text; displayReason only localizes the approval panel.
1113
+ const outcome = await this.approvalGateway().request({ toolName: 'dsh-hyperliquid', callId: exec.callId, agent: exec.agent, signal: exec.signal, reason,
1114
+ displayReason: { en: english, zh: reason } })
1017
1115
  if (outcome !== 'allowed-once') throw new Error(`用户未批准 live 操作(${outcome})`)
1018
1116
  }
1019
1117
 
@@ -1060,11 +1158,12 @@ export class HyperRemote extends TypertRemoteService {
1060
1158
  if (!checked.riskReducing && projectedMargin > risk.equity + 1e-8) throw new Error('模拟账户可用保证金不足')
1061
1159
  const budgetTicket = guard.reserve(checked.notionalUsd, { riskReducing: checked.riskReducing })
1062
1160
  let fill
1063
- try { fill = this.store.paperFill({ coin, isBuy, sz, px, reason: request?.reason ?? 'chat' }) }
1161
+ const sessionId = request?.[TOOL_CALL]?.sessionId ?? null
1162
+ try { fill = this.store.paperFill({ coin, isBuy, sz, px, reason: request?.reason ?? 'chat', sessionId }) }
1064
1163
  catch (err) { guard.release(budgetTicket); throw err }
1065
1164
  this.store.paperOrderRecord({ coin, isBuy, sz, px: request?.type === 'limit' ? Number(request.px) : px,
1066
1165
  filledPx: px, type: request?.type === 'limit' ? 'limit' : 'market', reduceOnly,
1067
- reason: request?.reason ?? '' })
1166
+ reason: request?.reason ?? '', sessionId })
1068
1167
  return { mode: 'paper', fill, marginUsed: projectedMargin, availableMargin: Math.max(0, risk.equity - projectedMargin),
1069
1168
  simulation: '固定滑点;未模拟盘口深度、手续费、资金费或强平' }
1070
1169
  }
@@ -1077,7 +1176,8 @@ export class HyperRemote extends TypertRemoteService {
1077
1176
  const fresh = await this.checkLiveOrder(orderSpec)
1078
1177
  if (Math.abs(fresh.mid / preview.mid - 1) > 0.01) throw new Error('价格已偏离预检超过 1%,请重新决策')
1079
1178
  if (!this.cfg.autonomousLive) {
1080
- await this.authorizeToolAction(`Hyperliquid ${this.cfg.network} ${coin} ${isBuy ? '买入' : '卖出'} ${sz},预估名义价值 ${fresh.notionalUsd.toFixed(2)} USD。是否允许本次交易?`, trusted.exec)
1179
+ await this.authorizeToolAction(`Hyperliquid ${this.cfg.network} ${coin} ${isBuy ? '买入' : '卖出'} ${sz},预估名义价值 ${fresh.notionalUsd.toFixed(2)} USD。是否允许本次交易?`, trusted.exec,
1180
+ `Hyperliquid ${this.cfg.network}: ${isBuy ? 'buy' : 'sell'} ${sz} ${coin}, estimated notional ${fresh.notionalUsd.toFixed(2)} USD. Allow this trade?`)
1081
1181
  }
1082
1182
  const { universe } = await this.markets()
1083
1183
  const idx = universe.findIndex((u) => u.name === coin)
@@ -1156,7 +1256,7 @@ export class HyperRemote extends TypertRemoteService {
1156
1256
  if (this.cfg.tradingMode === 'paper') throw new Error('paper 模式没有挂单')
1157
1257
  const exec = request?.[TOOL_CALL]?.exec
1158
1258
  if (!exec?.agent) throw new Error('live 撤单仅允许从 DSH agent 工具调用')
1159
- if (!this.cfg.autonomousLive || !this.cfg.allowedCoins.includes(coin)) await this.authorizeToolAction(`Hyperliquid ${this.cfg.network} 撤销 ${coin} 订单 oid=${oid}?`, exec)
1259
+ if (!this.cfg.autonomousLive || !this.cfg.allowedCoins.includes(coin)) await this.authorizeToolAction(`Hyperliquid ${this.cfg.network} 撤销 ${coin} 订单 oid=${oid}?`, exec, `Hyperliquid ${this.cfg.network}: cancel ${coin} order oid=${oid}?`)
1160
1260
  const { universe } = await this.markets()
1161
1261
  const idx = universe.findIndex((u) => u.name === coin)
1162
1262
  if (idx < 0) throw new Error(`未知合约 ${coin}`)
@@ -1194,7 +1294,7 @@ export class HyperRemote extends TypertRemoteService {
1194
1294
  if (!this.cfg.allowedCoins.includes(coin)) throw new Error(`交易限额:${coin} 不在允许交易币对内(allowedCoins)`)
1195
1295
  const exec = request?.[TOOL_CALL]?.exec
1196
1296
  if (!exec?.agent) throw new Error('live 杠杆变更仅允许从 DSH agent 工具调用')
1197
- if (!this.cfg.autonomousLive) await this.authorizeToolAction(`Hyperliquid ${this.cfg.network} 将 ${coin} 杠杆设为 ${leverage}x?`, exec)
1297
+ if (!this.cfg.autonomousLive) await this.authorizeToolAction(`Hyperliquid ${this.cfg.network} 将 ${coin} 杠杆设为 ${leverage}x?`, exec, `Hyperliquid ${this.cfg.network}: set ${coin} leverage to ${leverage}x?`)
1198
1298
  const res = await this.submitAction({ type: 'updateLeverage', asset: idx, isCross, leverage }, exec)
1199
1299
  assertExchangeAccepted(res)
1200
1300
  this.store.recordEvent('live.leverage', { coin, leverage, isCross })
@@ -1218,6 +1318,7 @@ export class HyperRemote extends TypertRemoteService {
1218
1318
  /** Stop the WS loop on teardown. */
1219
1319
  stopWs() {
1220
1320
  clearInterval(this.sweepTimer)
1321
+ clearInterval(this.alertTimer)
1221
1322
  this.client.close()
1222
1323
  }
1223
1324
  }
@@ -1385,7 +1486,7 @@ function paperRiskState(paper, mids, strict = false) {
1385
1486
 
1386
1487
  // Remote markers: every method the web client may call.
1387
1488
  for (const m of [
1388
- 'status', 'switchNetwork', 'switchTradingMode', 'markets', 'marketCatalog', 'marketDetail', 'chartMarks', 'drawChartMark', 'eraseChartMark', 'sessionWorkspace', 'updateSessionWorkspace', 'snapshot', 'candles', 'fundingHistory', 'account', 'fills', 'tradeHistory', 'portfolio',
1489
+ 'status', 'switchNetwork', 'switchTradingMode', 'updateRiskSettings', 'resetRiskSettings', 'markets', 'marketCatalog', 'marketDetail', 'chartMarks', 'drawChartMark', 'updateChartMark', 'eraseChartMark', 'sessionWorkspace', 'updateSessionWorkspace', 'snapshot', 'candles', 'fundingHistory', 'account', 'fills', 'tradeHistory', 'portfolio',
1389
1490
  'sessions', 'registerSession', 'removeSession', 'bindCoin', 'deskState',
1390
1491
  'eventsSince',
1391
1492
  ]) {
@@ -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/store.js CHANGED
@@ -5,14 +5,14 @@
5
5
  * @module lib/store.js
6
6
  */
7
7
 
8
- import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, rmSync } from 'node:fs'
8
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, rmSync, unlinkSync } from 'node:fs'
9
9
  import { dirname, join } from 'node:path'
10
10
  import { randomUUID, createHash } from 'node:crypto'
11
11
  import { canonicalMarketCoin } from './market-symbol.js'
12
12
 
13
13
  const PAPER_START_USD = 100_000
14
14
  const WORKSPACE_DEFAULTS = Object.freeze({
15
- interval: '15m', bookGrouping: 'full', chartTab: 'chart', tapeTab: 'book', bottomTab: 'positions', indicators: ['volume'],
15
+ interval: '15m', bookGrouping: 'full', chartTab: 'chart', tapeTab: 'book', bottomTab: 'positions', indicators: ['volume'], maLines: [],
16
16
  priceScale: 'normal', chartRanges: {}, chartLayers: { orders: true, fills: true, drawings: true },
17
17
  layout: { sessionsWidth: 232, chatWidth: 480, sessionsCollapsed: false, deskCollapsed: false, chatCollapsed: false },
18
18
  })
@@ -26,6 +26,7 @@ export class HyperStore {
26
26
  this.liveOrdersFile = join(dataDir, 'live-orders.json')
27
27
  this.liveDecisionsFile = join(dataDir, 'live-decisions.json')
28
28
  this.chartMarksFile = join(dataDir, 'chart-marks.json')
29
+ this.riskSettingsFile = join(dataDir, 'risk-settings.json')
29
30
  this.events = [] // UI event ring
30
31
  this.eventSeq = 0
31
32
  this.sessions = this.#readJson(this.sessionsFile, { sessions: {} })
@@ -109,6 +110,14 @@ export class HyperStore {
109
110
  catch { throw new Error(`会话文件损坏:${file}`) }
110
111
  }
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
+
112
121
  #migrateChartMarks() {
113
122
  if (!existsSync(this.chartMarksFile)) return
114
123
  const legacy = this.#readArtifact(this.chartMarksFile, [])
@@ -211,29 +220,94 @@ export class HyperStore {
211
220
  return this.sessions.sessions[sessionId] ?? null
212
221
  }
213
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
+
214
246
  listChartMarks(sessionId, coin, interval) {
215
- return this.#marks(sessionId).filter((mark) => mark.coin === coin && mark.interval === 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()]
216
255
  }
217
256
 
218
257
  addChartMark(mark) {
219
258
  const related = this.listChartMarks(mark.sessionId, mark.coin, mark.interval)
220
259
  if (related.length >= 30) throw new Error('当前图表最多保留 30 条标注,请先删除旧线')
221
- const marks = this.#marks(mark.sessionId)
260
+ const marks = mark.shared ? this.#shared() : this.#marks(mark.sessionId)
222
261
  if (marks.length >= 300) throw new Error('当前会话最多保留 300 条图表标注,请先删除旧线')
223
262
  const saved = { ...mark, id: randomUUID(), createdAt: Date.now() }
224
263
  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 })
264
+ if (mark.shared) this.#saveShared(); else this.#saveSession(mark.sessionId)
265
+ this.#notify(saved, mark.sessionId)
227
266
  return saved
228
267
  }
229
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
+
230
302
  removeChartMark(sessionId, id) {
231
- const marks = this.#marks(sessionId)
232
- const mark = marks.find((row) => row.id === 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)
233
307
  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 })
308
+ list.splice(list.indexOf(mark), 1)
309
+ if (inShared) this.#saveShared(); else this.#saveSession(sessionId)
310
+ this.#notify(mark, sessionId)
237
311
  return mark
238
312
  }
239
313
 
@@ -258,7 +332,7 @@ export class HyperStore {
258
332
  * Fill a paper order against the given mid with a simple slippage model.
259
333
  * @returns the fill record
260
334
  */
261
- paperFill({ coin, isBuy, sz, px, time = Date.now(), reason = 'order' }) {
335
+ paperFill({ coin, isBuy, sz, px, time = Date.now(), reason = 'order', sessionId = null }) {
262
336
  const szNum = Number(sz)
263
337
  const pxNum = Number(px)
264
338
  if (!Number.isFinite(szNum) || szNum <= 0) throw new Error('paper: 无效数量')
@@ -287,7 +361,8 @@ export class HyperStore {
287
361
  this.paper.positions[coin] = pos
288
362
  }
289
363
  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 }
364
+ // sessionId: the trading conversation whose decision produced this fill.
365
+ const fill = { coin, isBuy, sz: szNum, px: pxNum, notional, realized, time, reason, sessionId }
291
366
  this.paper.fills.unshift(fill)
292
367
  if (this.paper.fills.length > 200) this.paper.fills.length = 200
293
368
  this.#writeJson(this.paperFile, this.paper)