dsh-hyperliquid 0.1.0 → 0.3.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,12 @@ 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'
19
+ import { INDICATOR_IDS, normalizeIndicatorSettings } from './studies.js'
20
+ import { normalizeResearch } from './workspace-settings.js'
21
+ import { fundingWindow } from './review.js'
16
22
 
17
23
  const TOOL_CALL = Symbol('verified DSH tool call')
18
24
 
@@ -81,6 +87,7 @@ const ACCOUNT_MODE_TTL_MS = 30_000
81
87
  const TRADE_CAPACITY_TTL_MS = 15_000
82
88
  const SUB_SWEEP_MS = 30_000
83
89
  const SUB_IDLE_MS = 120_000
90
+ const ALERT_CHECK_MS = 2_000
84
91
  const BOOK_GROUPINGS = {
85
92
  full: null,
86
93
  '5': { nSigFigs: 5 },
@@ -121,6 +128,10 @@ export class HyperRemote extends TypertRemoteService {
121
128
  if (this.cfg.tradingAddress && !/^0x[0-9a-fA-F]{40}$/.test(this.cfg.tradingAddress)) throw new Error('tradingAddress 必须为 20 字节 EVM 地址')
122
129
  this.client = new HyperliquidClient({ network: this.cfg.network, privateKey: this.cfg.privateKey })
123
130
  this.store = new HyperStore(this.cfg.dataDir, this.cfg.network)
131
+ this.baseRiskSettings = Object.fromEntries(['allowedCoins', 'maxOrderUsd', 'maxDailyUsd', 'maxPositionUsd', 'maxLeverage'].map((key) => [key, this.cfg[key]]))
132
+ const savedRiskSettings = this.store.getRiskSettings()
133
+ this.riskSettingsOverride = savedRiskSettings != null
134
+ if (savedRiskSettings) Object.assign(this.cfg, normalizeRiskSettings(savedRiskSettings))
124
135
  this.guard = new TradingGuard(this.cfg.dataDir, { ...this.cfg, dataDir: this.cfg.dataDir })
125
136
  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
137
  this.marketsCache = null
@@ -149,6 +160,10 @@ export class HyperRemote extends TypertRemoteService {
149
160
  }
150
161
  }
151
162
  this.sweepTimer = setInterval(() => this.sweepSubs(), SUB_SWEEP_MS)
163
+ this.sweepTimer.unref?.()
164
+ this.alertSides = new Map() // drawing id → last side of the mid price (-1 below, 1 above)
165
+ this.alertTimer = setInterval(() => { try { this.checkDrawingAlerts() } catch { /* next tick retries */ } }, ALERT_CHECK_MS)
166
+ this.alertTimer.unref?.()
152
167
  }
153
168
 
154
169
  sweepSubs() {
@@ -443,7 +458,7 @@ export class HyperRemote extends TypertRemoteService {
443
458
  const sessionId = String(request?.sessionId ?? '')
444
459
  if (!this.store.getSession(sessionId)) return { marks: [] }
445
460
  const coin = requireCoin(request)
446
- const interval = requireInterval(request?.interval ?? this.desk.interval)
461
+ const interval = request?.allIntervals === true ? null : requireInterval(request?.interval ?? this.desk.interval)
447
462
  return { marks: this.store.listChartMarks(sessionId, coin, interval) }
448
463
  }
449
464
 
@@ -451,30 +466,100 @@ export class HyperRemote extends TypertRemoteService {
451
466
  this.requireTradingSession(sessionId)
452
467
  const coin = requireCoin(request)
453
468
  const interval = requireInterval(request?.interval ?? this.desk.interval)
454
- const kind = request?.kind === 'trend' ? 'trend' : 'level'
469
+ const kind = request?.kind ?? 'level'
455
470
  const role = ['support', 'resistance', 'neutral'].includes(request?.role) ? request.role : 'neutral'
456
471
  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
- }
472
+ const maxLabel = TEXT_KINDS.includes(kind) ? 200 : 60
473
+ if (!label || label.length > maxLabel) throw new Error(`图表标注需要 1-${maxLabel} 字${TEXT_KINDS.includes(kind) ? '文本' : '标签'}`)
474
+ if (kind === 'icon' && !isIconLabel(label)) throw new Error(`图标 label 必须为 ${ICON_NAMES.join('/')} 或贴图库中的 emoji:<贴纸>`)
475
+ const mark = { sessionId, coin, interval, kind, role, label, source, ...drawingGeometry(kind, request), ...drawingStyle({ ...request, kind }) }
475
476
  return this.store.addChartMark(mark)
476
477
  }
477
478
 
479
+ /** Replace a session chart's MA lines (agent path); the desk reloads on the `chart.ma` event. */
480
+ setSessionMaLines(sessionId, lines) {
481
+ this.requireTradingSession(sessionId)
482
+ const maLines = normalizeMaLines(lines)
483
+ this.store.updateSessionWorkspace(sessionId, { maLines })
484
+ this.store.recordEvent('chart.ma', { sessionId, ids: maLines.map((line) => line.id) })
485
+ return maLines
486
+ }
487
+
488
+ /** Agent sets what the session chart shows (interval, indicator panes); the desk follows on `chart.view`. */
489
+ setSessionChartView(sessionId, { interval, show = [], hide = [] } = {}) {
490
+ this.requireTradingSession(sessionId)
491
+ const unknown = [...show, ...hide].filter((id) => !INDICATOR_IDS.includes(id))
492
+ if (unknown.length) throw new Error(`未知指标 ${unknown.join(',')},可选:${INDICATOR_IDS.join(', ')}`)
493
+ const current = this.store.getSessionWorkspace(sessionId)
494
+ const patch = { indicators: [...new Set([...(current.indicators ?? []).filter((id) => !hide.includes(id)), ...show])] }
495
+ if (interval != null) patch.interval = requireInterval(interval)
496
+ const next = this.store.updateSessionWorkspace(sessionId, patch)
497
+ if (this.desk?.sessionId === sessionId && patch.interval) this.desk.interval = patch.interval
498
+ this.store.recordEvent('chart.view', { sessionId, interval: next.interval, indicators: next.indicators })
499
+ return next
500
+ }
501
+
502
+ /** Move, reshape, restyle or relabel one drawing; geometry is re-validated as a whole. */
503
+ editChartMark(id, patch, sessionId) {
504
+ this.requireTradingSession(sessionId)
505
+ const current = this.store.getChartMark(sessionId, String(id ?? ''))
506
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new Error('画线修改无效')
507
+ const merged = { ...current, ...patch }
508
+ const geometryKeys = ['price', 'fromTime', 'fromPrice', 'toTime', 'toPrice', 'points']
509
+ const fields = { ...drawingStyle({ ...patch, kind: current.kind }) }
510
+ if (patch?.alert === true) fields.alertTriggeredAt = undefined
511
+ // null clears a style back to its default (undo of a first restyle relies on this).
512
+ for (const key of ['color', 'lineWidth', 'lineStyle', 'rows', 'valueArea']) if (key in patch && patch[key] === null) fields[key] = undefined
513
+ if (geometryKeys.some((key) => key in patch)) Object.assign(fields, drawingGeometry(current.kind, merged))
514
+ if ('label' in patch) {
515
+ const label = String(patch.label ?? '').trim()
516
+ const maxLabel = TEXT_KINDS.includes(current.kind) ? 200 : 60
517
+ if (!label || label.length > maxLabel) throw new Error(`图表标注需要 1-${maxLabel} 字`)
518
+ fields.label = label
519
+ }
520
+ if (current.locked && !('locked' in patch) && Object.keys(fields).length) throw new Error('该画线已锁定,先解锁再修改')
521
+ return this.store.updateChartMark(sessionId, current.id, fields)
522
+ }
523
+
524
+ async updateChartMark(request) {
525
+ return this.editChartMark(request?.id, request?.patch, String(request?.sessionId ?? ''))
526
+ }
527
+
528
+ /**
529
+ * Drawing alerts: when the mid price crosses an alert-enabled line it fires once (the alert
530
+ * switches off, the time is recorded) and the desk gets a `chart.alert` event.
531
+ * @param mids - coin → mid (defaults to the live allMids snapshot).
532
+ */
533
+ checkDrawingAlerts(now = Date.now(), mids = null) {
534
+ const watched = this.store.allChartMarks?.().filter((mark) => mark.alert && ALERT_KINDS.includes(mark.kind)) ?? []
535
+ for (const id of [...this.alertSides.keys()]) if (!watched.some((mark) => mark.id === id)) this.alertSides.delete(id)
536
+ if (!watched.length) return []
537
+ let book = mids
538
+ if (!book) {
539
+ const key = this.touch(keys.allMids())
540
+ const snap = this.client.snapshot(key)
541
+ if (!snap || now - snap.at > 10_000) return []
542
+ book = snap.data?.mids ?? snap.data
543
+ }
544
+ const fired = []
545
+ for (const mark of watched) {
546
+ const mid = Number(book?.[mark.coin])
547
+ const line = linePriceAt(mark, now)
548
+ if (!Number.isFinite(mid) || line == null || !Number.isFinite(line)) continue
549
+ const side = mid > line ? 1 : mid < line ? -1 : 0
550
+ const before = this.alertSides.get(mark.id)
551
+ if (side !== 0) this.alertSides.set(mark.id, side)
552
+ if (before == null || side === 0 || side === before) continue
553
+ this.alertSides.delete(mark.id)
554
+ const saved = this.store.updateChartMark(mark.sessionId, mark.id, { alert: false, alertTriggeredAt: now })
555
+ const event = { sessionId: mark.shared ? null : mark.sessionId, shared: Boolean(mark.shared), coin: mark.coin, id: mark.id, label: mark.label, kind: mark.kind,
556
+ price: mid, line, direction: side > 0 ? 'up' : 'down', at: now }
557
+ this.store.recordEvent('chart.alert', event)
558
+ fired.push({ ...event, mark: saved })
559
+ }
560
+ return fired
561
+ }
562
+
478
563
  removeChartMark(id, sessionId) {
479
564
  this.requireTradingSession(sessionId)
480
565
  return this.store.removeChartMark(sessionId, String(id ?? ''))
@@ -505,21 +590,29 @@ export class HyperRemote extends TypertRemoteService {
505
590
  patch.tapeTab = input.tapeTab
506
591
  }
507
592
  if ('bottomTab' in input) {
508
- if (!['positions', 'orders', 'account'].includes(input.bottomTab)) throw new Error('账户页签无效')
593
+ if (!['positions', 'orders', 'account', 'review'].includes(input.bottomTab)) throw new Error('账户页签无效')
509
594
  patch.bottomTab = input.bottomTab
510
595
  }
511
596
  if ('indicators' in input) {
512
- const allowed = ['volume', 'sma20', 'sma60', 'ema20', 'ema50', 'bollinger20', 'rsi14', 'macd']
597
+ const allowed = INDICATOR_IDS
513
598
  if (!Array.isArray(input.indicators) || input.indicators.length > allowed.length || input.indicators.some((id) => !allowed.includes(id))) throw new Error('图表指标无效')
514
599
  patch.indicators = [...new Set(input.indicators)]
515
600
  }
601
+ if ('maLines' in input) patch.maLines = normalizeMaLines(input.maLines)
602
+ if ('indicatorSettings' in input) patch.indicatorSettings = normalizeIndicatorSettings(input.indicatorSettings)
603
+ if ('research' in input) patch.research = normalizeResearch(input.research)
604
+ if ('showDataWindow' in input) { if (typeof input.showDataWindow !== 'boolean') throw Error('数据窗口设置无效'); patch.showDataWindow = input.showDataWindow }
605
+ if ('chartType' in input) {
606
+ if (!['candles', 'bars', 'line', 'area', 'heikinAshi'].includes(input.chartType)) throw new Error('图表类型无效')
607
+ patch.chartType = input.chartType
608
+ }
516
609
  if ('priceScale' in input) {
517
610
  if (!['normal', 'log', 'percentage'].includes(input.priceScale)) throw new Error('价格刻度无效')
518
611
  patch.priceScale = input.priceScale
519
612
  }
520
613
  if ('chartLayers' in input) {
521
614
  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('图表图层无效')
615
+ 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
616
  patch.chartLayers = layers
524
617
  }
525
618
  if ('layout' in input) {
@@ -556,9 +649,30 @@ export class HyperRemote extends TypertRemoteService {
556
649
  return result
557
650
  }
558
651
 
652
+ async indicatorTemplates(request) {
653
+ const action = request?.action ?? 'list'
654
+ if (action === 'list') return { templates: this.store.indicatorTemplates() }
655
+ if (action === 'remove') { this.store.removeIndicatorTemplate(String(request?.id ?? '')); return { templates: this.store.indicatorTemplates() } }
656
+ if (action !== 'save') throw Error('指标模板操作无效')
657
+ const name = String(request?.name ?? '').trim()
658
+ if (!name || name.length > 60) throw Error('模板名称需要 1–60 个字符')
659
+ const indicators = request?.indicators
660
+ if (!Array.isArray(indicators) || indicators.length > INDICATOR_IDS.length || indicators.some((id) => !INDICATOR_IDS.includes(id))) throw Error('模板指标无效')
661
+ const id = request?.id
662
+ if (id != null && !this.store.indicatorTemplates().some((row) => row.id === id)) throw Error('指标模板不存在')
663
+ const template = this.store.saveIndicatorTemplate({ id, name, indicators: [...new Set(indicators)], indicatorSettings: normalizeIndicatorSettings(request?.indicatorSettings), maLines: normalizeMaLines(request?.maLines ?? []) })
664
+ return { template, templates: this.store.indicatorTemplates() }
665
+ }
666
+
667
+ async updatePaperCosts(request) {
668
+ if (this.activeTradeOps > 0) throw Error('交易操作仍在进行,请稍后修改模拟成本')
669
+ return { paperCosts: this.store.setPaperCosts(request?.costs) }
670
+ }
671
+
559
672
  async drawChartMark(request) {
560
673
  const sessionId = String(request?.sessionId ?? '')
561
- return this.addChartMark(request, sessionId, 'user')
674
+ // Undo re-creates a deleted agent drawing; keep its origin so the chart still labels it as the agent's.
675
+ return this.addChartMark(request, sessionId, request?.restoreSource === 'agent' ? 'agent' : 'user')
562
676
  }
563
677
 
564
678
  async eraseChartMark(request) {
@@ -616,6 +730,31 @@ export class HyperRemote extends TypertRemoteService {
616
730
  return { mode }
617
731
  }
618
732
 
733
+ /** Persist limits as one atomic override and update both live/paper guards. */
734
+ async updateRiskSettings(request) {
735
+ if (this.activeTradeOps > 0) throw new Error('交易操作仍在进行,暂不能修改限额')
736
+ const limits = normalizeRiskSettings(request?.limits)
737
+ this.store.saveRiskSettings(limits)
738
+ this.applyRiskSettings(limits, true)
739
+ return { riskLimits: { ...limits, usedTodayUsd: this.usedTodayUsd() } }
740
+ }
741
+
742
+ async resetRiskSettings(request) {
743
+ if (this.activeTradeOps > 0) throw new Error('交易操作仍在进行,暂不能修改限额')
744
+ this.store.clearRiskSettings()
745
+ this.applyRiskSettings(this.baseRiskSettings, false)
746
+ return { riskLimits: { ...this.baseRiskSettings, usedTodayUsd: this.usedTodayUsd() } }
747
+ }
748
+
749
+ applyRiskSettings(limits, override) {
750
+ Object.assign(this.cfg, limits)
751
+ Object.assign(this.guard.limits, limits)
752
+ Object.assign(this.paperGuard.limits, limits)
753
+ this.guard.plans.clear()
754
+ this.riskSettingsOverride = override
755
+ this.store.recordEvent('risk.changed')
756
+ }
757
+
619
758
  /** Today's (UTC) notional already counted against maxDailyUsd for the active mode. */
620
759
  usedTodayUsd() {
621
760
  const guard = this.cfg.tradingMode === 'paper' ? this.paperGuard : this.guard
@@ -628,10 +767,12 @@ export class HyperRemote extends TypertRemoteService {
628
767
  const address = await this.resolveAddress()
629
768
  const wallet = this.cfg.signer === 'web3' ? this.walletService() : null
630
769
  return {
770
+ paperCosts: this.store.paperCosts?.() ?? { feeBps: 4.5, slippageBps: 5 },
631
771
  network: this.cfg.network,
632
772
  tradingMode: this.cfg.tradingMode,
633
773
  signer: this.cfg.signer,
634
774
  autonomousLive: this.cfg.autonomousLive,
775
+ riskSettingsSource: this.riskSettingsOverride ? 'desk' : 'plugin',
635
776
  riskLimits: { allowedCoins: this.cfg.allowedCoins, maxOrderUsd: this.cfg.maxOrderUsd, maxDailyUsd: this.cfg.maxDailyUsd, maxPositionUsd: this.cfg.maxPositionUsd, maxLeverage: this.cfg.maxLeverage,
636
777
  // Day budget of the ledger that applies to the current mode (paper and live are separate).
637
778
  usedTodayUsd: this.usedTodayUsd() },
@@ -737,7 +878,7 @@ export class HyperRemote extends TypertRemoteService {
737
878
  marginUsed: risk.marksConfirmed ? risk.marginUsed : null,
738
879
  availableMargin: risk.marksConfirmed ? Math.max(0, risk.equity - risk.marginUsed) : null,
739
880
  marksConfirmed: risk.marksConfirmed, positions: risk.positions, fills: paper.fills.slice(0, 50),
740
- simulation: '固定滑点;未模拟盘口深度、手续费、资金费或强平' }
881
+ simulation: '已模拟配置的滑点与手续费;未模拟盘口深度、资金费或强平' }
741
882
  }
742
883
  const address = await this.resolveAddress()
743
884
  if (!address) throw new Error('live 模式需要钱包(config.privateKey 或 signer=web3)')
@@ -830,34 +971,58 @@ export class HyperRemote extends TypertRemoteService {
830
971
  }
831
972
 
832
973
  /** Explicitly loaded history: these weighted API calls must not join the 3s account poll. */
833
- async tradeHistory() {
834
- if (this.cfg.tradingMode === 'paper') {
974
+ async tradeHistory(request) {
975
+ const client = this.client, mode = this.cfg.tradingMode, network = this.cfg.network
976
+ if (request?.days != null && ![7, 30, 90].includes(request.days)) throw Error('复盘支持最近 7/30/90 天')
977
+ const end = Date.now(), requestedStart = request?.days != null ? end - request.days * 86_400_000 : request?.startTime == null ? null : Number(request.startTime)
978
+ if (requestedStart != null && (!Number.isFinite(requestedStart) || requestedStart < end - 90 * 86_400_000 || requestedStart >= end)) throw Error('复盘时间范围必须在最近 90 天内')
979
+ const start = requestedStart ?? Math.max(0, end - 30 * 86_400_000)
980
+ let result
981
+ if (mode === 'paper') {
835
982
  const paper = this.store.paperAccount()
836
- return { mode: 'paper', orders: paper.orders.slice(0, 200), fills: paper.fills.slice(0, 200) }
983
+ result = { mode: 'paper', orders: paper.orders.slice(0, 5000), fills: paper.fills.slice(0, 5000), paperCosts: this.store.paperCosts(), funding: [],
984
+ coverage: { fundingStatus: 'not-simulated', fillsLimited: paper.fills.length >= 200, ordersLimited: paper.orders.length >= 200 } }
985
+ } else {
986
+ const address = await this.resolveAddress()
987
+ if (!address) throw Error('未配置交易主账户地址')
988
+ const [orders, fills] = await Promise.all([client.historicalOrders(address), client.userFills(address)])
989
+ if (!Array.isArray(orders) || !Array.isArray(fills)) throw Error('交易所历史数据响应无效')
990
+ const decisions = this.store.liveDecisions ?? {}
991
+ const rows = orders.slice(0, 2000).map((entry) => {
992
+ const order = entry.order ?? entry, local = this.store.liveOrders?.[order.cloid], decision = decisions[local?.decisionId]
993
+ return { ...order, status: entry.status ?? local?.status ?? 'unknown', statusTimestamp: entry.statusTimestamp,
994
+ referencePx: decision?.preview?.mid ?? null,
995
+ reason: local?.thesis ?? decision?.thesis ?? null, sessionId: local?.sessionId ?? decision?.sessionId ?? null }
996
+ })
997
+ result = { mode: 'live', orders: rows, fills: fills.slice(0, 2000), funding: [],
998
+ coverage: { fundingStatus: 'not-requested', fillsLimited: fills.length >= 2000, ordersLimited: orders.length >= 2000 },
999
+ localOrders: Object.entries(this.store.liveOrders ?? {}).map(([cloid, row]) => ({ cloid, coin: row.coin, status: row.status, at: row.updatedAt ?? row.at, sessionId: row.sessionId })) }
1000
+ if (request?.includeFunding) {
1001
+ try {
1002
+ const funding = await fundingWindow(client, address, start, end)
1003
+ result.funding = funding.rows
1004
+ result.coverage.fundingStatus = funding.status
1005
+ } catch (error) { result.coverage.fundingStatus = 'error'; result.coverage.fundingError = String(error?.message ?? error) }
1006
+ }
837
1007
  }
838
- const address = await this.resolveAddress()
839
- if (!address) throw new Error('未配置交易主账户地址')
840
- const [orders, fills] = await Promise.all([
841
- this.client.historicalOrders(address), this.client.userFills(address),
842
- ])
843
- if (!Array.isArray(orders) || !Array.isArray(fills)) throw new Error('交易所历史数据响应无效')
844
- const localByCloid = this.store.liveOrders
845
- const decisions = this.store.liveDecisions
846
- return { mode: 'live', orders: orders.slice(0, 2000).map((entry) => {
847
- const order = entry.order ?? entry
848
- const local = localByCloid[order.cloid]
849
- return { ...order, status: entry.status ?? local?.status ?? 'unknown', statusTimestamp: entry.statusTimestamp,
850
- reason: local?.thesis ?? (local?.decisionId ? decisions[local.decisionId]?.thesis ?? null : null) }
851
- }), fills: fills.slice(0, 2000) }
1008
+ if (requestedStart != null) {
1009
+ result.fills = result.fills.filter((row) => Number(row.time) >= start && Number(row.time) <= end)
1010
+ const ids = new Set(result.fills.filter((row) => row.oid != null).map((row) => `${row.coin}:${row.oid}`))
1011
+ result.orders = result.orders.filter((row) => Number(row.timestamp ?? row.time) >= start || ids.has(`${row.coin}:${row.oid}`))
1012
+ }
1013
+ return { ...result, network, observedAt: Date.now(), coverage: { ...result.coverage, startTime: start, endTime: end, bounded: true } }
852
1014
  }
853
1015
 
854
1016
  /** Hyperliquid portfolio samples are display data, not an accounting ledger. */
855
- async portfolio() {
1017
+ async portfolio(request) {
856
1018
  if (this.cfg.tradingMode === 'paper') {
857
1019
  const account = await this.account({})
858
1020
  const fills = this.store.paperAccount().fills
859
1021
  return { mode: 'paper', equity: account.equity, unrealizedPnl: account.uPnl,
860
- realizedPnl: fills.reduce((sum, fill) => sum + Number(fill.realized ?? 0), 0), series: {} }
1022
+ realizedPnl: fills.reduce((sum, fill) => sum + Number(fill.realized ?? 0), 0),
1023
+ fees: fills.reduce((sum, fill) => sum + Number(fill.fee ?? 0), 0),
1024
+ netRealizedPnl: fills.some((fill) => fill.fee == null) ? null : fills.reduce((sum, fill) => sum + Number(fill.realized ?? 0) - Number(fill.fee), 0),
1025
+ retainedFills: fills.length, series: {} }
861
1026
  }
862
1027
  const address = await this.resolveAddress()
863
1028
  if (!address) throw new Error('未配置交易主账户地址')
@@ -1011,9 +1176,11 @@ export class HyperRemote extends TypertRemoteService {
1011
1176
  if (!sessionId || !this.store.getSession(sessionId)) throw new Error('交易操作必须来自已登记的交易会话')
1012
1177
  }
1013
1178
 
1014
- async authorizeToolAction(reason, exec) {
1179
+ async authorizeToolAction(reason, exec, english = reason) {
1015
1180
  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 })
1181
+ // reason stays the audit text; displayReason only localizes the approval panel.
1182
+ const outcome = await this.approvalGateway().request({ toolName: 'dsh-hyperliquid', callId: exec.callId, agent: exec.agent, signal: exec.signal, reason,
1183
+ displayReason: { en: english, zh: reason } })
1017
1184
  if (outcome !== 'allowed-once') throw new Error(`用户未批准 live 操作(${outcome})`)
1018
1185
  }
1019
1186
 
@@ -1044,29 +1211,32 @@ export class HyperRemote extends TypertRemoteService {
1044
1211
  if (!Number.isFinite(limit) || limit <= 0) throw new Error('限价单需要有效 px')
1045
1212
  if ((isBuy && limit < mid) || (!isBuy && limit > mid)) throw new Error('paper 当前只支持可立即成交的限价单')
1046
1213
  }
1047
- const slip = Number(request?.slippagePct)
1048
- const pct = Number.isFinite(slip) ? Math.min(Math.max(slip, 0), 10) / 100 : 0.0005
1214
+ const costs = this.store.paperCosts()
1215
+ const slip = request?.slippagePct == null ? null : Number(request.slippagePct)
1216
+ const pct = slip != null && Number.isFinite(slip) ? Math.min(Math.max(slip, 0), 10) / 100 : costs.slippageBps / 10_000
1049
1217
  const px = mid * (isBuy ? 1 + pct : 1 - pct)
1050
1218
  if (request?.type === 'limit' && (isBuy ? px > Number(request.px) : px < Number(request.px))) throw new Error('paper 模拟滑点后无法满足限价')
1051
1219
  const paper = this.store.paperAccount()
1052
- const risk = paperRiskState(paper, { ...await safeMids(this), [coin]: px }, true)
1220
+ const risk = paperRiskState(paper, { ...await safeMids(this), [coin]: mid }, true)
1053
1221
  const positionSize = Number(paper.positions[coin]?.size ?? 0)
1054
1222
  const leverage = Number(paper.positions[coin]?.leverage ?? paper.leverageByCoin?.[coin] ?? 1)
1055
1223
  const otherExposureUsd = risk.positions.reduce((sum, p) => sum + (p.coin === coin ? 0 : Math.abs(p.size) * p.markPx), 0)
1056
1224
  const guard = 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' })
1057
1225
  const checked = guard.validate({ coin, isBuy, sz, price: px, positionSize, otherExposureUsd, accountValue: risk.equity, leverage, reduceOnly })
1058
- const currentMargin = Math.abs(positionSize) * px / leverage
1059
- const projectedMargin = risk.marginUsed - currentMargin + Math.abs(checked.nextSize) * px / leverage
1060
- if (!checked.riskReducing && projectedMargin > risk.equity + 1e-8) throw new Error('模拟账户可用保证金不足')
1226
+ const currentMargin = Math.abs(positionSize) * mid / leverage
1227
+ const projectedMargin = risk.marginUsed - currentMargin + Math.abs(checked.nextSize) * mid / leverage
1228
+ const projectedEquity = risk.equity - checked.notionalUsd * costs.feeBps / 10_000 - sz * (isBuy ? px - mid : mid - px)
1229
+ if (!checked.riskReducing && projectedMargin > projectedEquity + 1e-8) throw new Error('模拟账户可用保证金不足')
1061
1230
  const budgetTicket = guard.reserve(checked.notionalUsd, { riskReducing: checked.riskReducing })
1062
1231
  let fill
1063
- try { fill = this.store.paperFill({ coin, isBuy, sz, px, reason: request?.reason ?? 'chat' }) }
1232
+ const sessionId = request?.[TOOL_CALL]?.sessionId ?? null
1233
+ try { fill = this.store.paperFill({ coin, isBuy, sz, px, reason: request?.reason ?? 'chat', sessionId, feeBps: costs.feeBps, referencePx: mid, slippageBps: pct * 10_000 }) }
1064
1234
  catch (err) { guard.release(budgetTicket); throw err }
1065
- this.store.paperOrderRecord({ coin, isBuy, sz, px: request?.type === 'limit' ? Number(request.px) : px,
1235
+ this.store.paperOrderRecord({ oid: fill.oid, referencePx: mid, coin, isBuy, sz, px: request?.type === 'limit' ? Number(request.px) : px,
1066
1236
  filledPx: px, type: request?.type === 'limit' ? 'limit' : 'market', reduceOnly,
1067
- reason: request?.reason ?? '' })
1068
- return { mode: 'paper', fill, marginUsed: projectedMargin, availableMargin: Math.max(0, risk.equity - projectedMargin),
1069
- simulation: '固定滑点;未模拟盘口深度、手续费、资金费或强平' }
1237
+ reason: request?.reason ?? '', sessionId })
1238
+ return { mode: 'paper', fill, marginUsed: projectedMargin, availableMargin: Math.max(0, projectedEquity - projectedMargin),
1239
+ simulation: '已模拟配置的滑点与手续费;未模拟盘口深度、资金费或强平' }
1070
1240
  }
1071
1241
  // live
1072
1242
  const trusted = request?.[TOOL_CALL]
@@ -1077,7 +1247,8 @@ export class HyperRemote extends TypertRemoteService {
1077
1247
  const fresh = await this.checkLiveOrder(orderSpec)
1078
1248
  if (Math.abs(fresh.mid / preview.mid - 1) > 0.01) throw new Error('价格已偏离预检超过 1%,请重新决策')
1079
1249
  if (!this.cfg.autonomousLive) {
1080
- await this.authorizeToolAction(`Hyperliquid ${this.cfg.network} ${coin} ${isBuy ? '买入' : '卖出'} ${sz},预估名义价值 ${fresh.notionalUsd.toFixed(2)} USD。是否允许本次交易?`, trusted.exec)
1250
+ await this.authorizeToolAction(`Hyperliquid ${this.cfg.network} ${coin} ${isBuy ? '买入' : '卖出'} ${sz},预估名义价值 ${fresh.notionalUsd.toFixed(2)} USD。是否允许本次交易?`, trusted.exec,
1251
+ `Hyperliquid ${this.cfg.network}: ${isBuy ? 'buy' : 'sell'} ${sz} ${coin}, estimated notional ${fresh.notionalUsd.toFixed(2)} USD. Allow this trade?`)
1081
1252
  }
1082
1253
  const { universe } = await this.markets()
1083
1254
  const idx = universe.findIndex((u) => u.name === coin)
@@ -1156,7 +1327,7 @@ export class HyperRemote extends TypertRemoteService {
1156
1327
  if (this.cfg.tradingMode === 'paper') throw new Error('paper 模式没有挂单')
1157
1328
  const exec = request?.[TOOL_CALL]?.exec
1158
1329
  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)
1330
+ 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
1331
  const { universe } = await this.markets()
1161
1332
  const idx = universe.findIndex((u) => u.name === coin)
1162
1333
  if (idx < 0) throw new Error(`未知合约 ${coin}`)
@@ -1194,7 +1365,7 @@ export class HyperRemote extends TypertRemoteService {
1194
1365
  if (!this.cfg.allowedCoins.includes(coin)) throw new Error(`交易限额:${coin} 不在允许交易币对内(allowedCoins)`)
1195
1366
  const exec = request?.[TOOL_CALL]?.exec
1196
1367
  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)
1368
+ 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
1369
  const res = await this.submitAction({ type: 'updateLeverage', asset: idx, isCross, leverage }, exec)
1199
1370
  assertExchangeAccepted(res)
1200
1371
  this.store.recordEvent('live.leverage', { coin, leverage, isCross })
@@ -1218,6 +1389,7 @@ export class HyperRemote extends TypertRemoteService {
1218
1389
  /** Stop the WS loop on teardown. */
1219
1390
  stopWs() {
1220
1391
  clearInterval(this.sweepTimer)
1392
+ clearInterval(this.alertTimer)
1221
1393
  this.client.close()
1222
1394
  }
1223
1395
  }
@@ -1385,7 +1557,7 @@ function paperRiskState(paper, mids, strict = false) {
1385
1557
 
1386
1558
  // Remote markers: every method the web client may call.
1387
1559
  for (const m of [
1388
- 'status', 'switchNetwork', 'switchTradingMode', 'markets', 'marketCatalog', 'marketDetail', 'chartMarks', 'drawChartMark', 'eraseChartMark', 'sessionWorkspace', 'updateSessionWorkspace', 'snapshot', 'candles', 'fundingHistory', 'account', 'fills', 'tradeHistory', 'portfolio',
1560
+ 'indicatorTemplates', 'updatePaperCosts', 'status', 'switchNetwork', 'switchTradingMode', 'updateRiskSettings', 'resetRiskSettings', 'markets', 'marketCatalog', 'marketDetail', 'chartMarks', 'drawChartMark', 'updateChartMark', 'eraseChartMark', 'sessionWorkspace', 'updateSessionWorkspace', 'snapshot', 'candles', 'fundingHistory', 'account', 'fills', 'tradeHistory', 'portfolio',
1389
1561
  'sessions', 'registerSession', 'removeSession', 'bindCoin', 'deskState',
1390
1562
  'eventsSince',
1391
1563
  ]) {
@@ -0,0 +1,3 @@
1
+ export interface ReviewRecord { id: string; coin: string; oid: string | number | null; time: number | null; px: number | null; size: number | null; isBuy: boolean | null; gross: number | null; fee: number | null; feeToken: string; net: number | null; reference: number | null; slippageBps: number | null; closedSize: number | null; reason: string; sessionId: string | null; flags: string[]; source: string }
2
+ export declare function fundingWindow(client: any, address: string, startTime: number, endTime: number): Promise<{rows: any[]; status: string; pages: number}>
3
+ export declare function reviewLedger(payload: any, coin?: string): { records: ReviewRecord[]; orders: any[]; duplicates: number; fees: Record<string, number>; gross: number | null; tradingNet: number | null; funding: number | null; net: number | null; closeCount: number; missingReasons: number; unmatched: number; gaps: number; unsupported: number }
package/lib/review.js ADDED
@@ -0,0 +1,79 @@
1
+ /** Evidence over a bounded history window; missing fields remain unknown. No balance inference. */
2
+ const numeric = (value) => value == null || value === '' || !Number.isFinite(Number(value)) ? null : Number(value)
3
+ const key = (row) => row?.oid == null || !row.coin ? null : `${row.coin}:${String(row.oid)}`
4
+ const core = (coin) => typeof coin === 'string' && coin.trim().length > 0 && !coin.startsWith('#') && !coin.includes(':') && !coin.startsWith('@') && !coin.includes('/')
5
+ export async function fundingWindow(client, address, startTime, endTime) {
6
+ const rows = [], seen = new Set(); let cursor = startTime, partial = false
7
+ for (let page = 0; page < 12; page++) {
8
+ const result = await client.info('userFunding', { user: address, startTime: cursor, endTime })
9
+ if (!Array.isArray(result)) throw Error('资金费历史响应无效')
10
+ const valid = result.filter((row) => Number.isFinite(Number(row.time)) && Number(row.time) >= cursor && Number(row.time) <= endTime && row.delta?.type === 'funding')
11
+ if (valid.length !== result.length) partial = true
12
+ for (const row of valid) {
13
+ const id = `${row.time}:${row.hash ?? ''}:${row.delta.coin}:${row.delta.usdc}`
14
+ if (!seen.has(id)) { seen.add(id); rows.push(row) }
15
+ }
16
+ if (result.length < 500) return { rows, status: partial ? 'partial' : 'complete', pages: page + 1 }
17
+ const last = Math.max(...valid.map((row) => Number(row.time)))
18
+ if (!Number.isFinite(last) || last <= cursor) return { rows, status: 'partial', pages: page + 1 }
19
+ // Inclusive boundary: repeat the final timestamp and deduplicate, avoiding lost peers.
20
+ cursor = last
21
+ }
22
+ return { rows, status: 'partial', pages: 12 }
23
+ }
24
+ export function reviewLedger(payload, coin = '') {
25
+ const orders = (payload.orders ?? []).filter((row) => !coin || row.coin === coin)
26
+ const allFills = (payload.fills ?? []).filter((row) => !coin || row.coin === coin)
27
+ const byOrder = new Map(orders.filter((row) => key(row)).map((row) => [key(row), row]))
28
+ const seen = new Set(), records = []; let duplicates = 0
29
+ for (const [index, fill] of allFills.entries()) {
30
+ const id = fill.tid != null ? `${fill.coin}:${fill.tid}` : payload.mode === 'paper' && fill.oid ? `${fill.coin}:${fill.oid}` : `unidentified:${index}`
31
+ if (seen.has(id)) { duplicates++; continue } seen.add(id)
32
+ const order = byOrder.get(key(fill)), pnl = numeric(fill.closedPnl ?? fill.realized), fee = numeric(fill.fee)
33
+ const feeToken = String(fill.feeToken ?? '').trim()
34
+ const px = numeric(fill.px), size = numeric(fill.sz), buy = typeof fill.isBuy === 'boolean' ? fill.isBuy : fill.side === 'B' ? true : fill.side === 'A' ? false : null
35
+ const reference = numeric(fill.referencePx ?? order?.referencePx)
36
+ const reason = fill.reason || order?.reason || ''
37
+ const closed = numeric(fill.closedSize) ?? (buy != null && numeric(fill.startPosition) != null && Math.sign(Number(fill.startPosition)) !== (buy ? 1 : -1) ? Math.min(Math.abs(Number(fill.startPosition)), size ?? 0) : /^Close/.test(fill.dir ?? '') ? size : null)
38
+ const eligible = core(fill.coin) && feeToken === 'USDC'
39
+ const net = eligible && pnl != null && fee != null ? pnl - fee : null
40
+ const flags = []
41
+ if (!order) flags.push('unmatched-order')
42
+ if (px == null || px <= 0) flags.push('missing-price')
43
+ if (size == null || size <= 0) flags.push('missing-size')
44
+ if (numeric(fill.time) == null) flags.push('missing-time')
45
+ if (buy == null) flags.push('missing-side')
46
+ if (!reason || /^(session:|tool$|chat$|order$)/.test(reason)) flags.push('missing-reason')
47
+ if (fee == null || !feeToken) flags.push('missing-fee')
48
+ if (pnl == null) flags.push('missing-pnl')
49
+ if (fill.tid == null && payload.mode !== 'paper') flags.push('missing-trade-id')
50
+ records.push({ id, coin: fill.coin, oid: fill.oid ?? null, time: numeric(fill.time), px, size, isBuy: buy, gross: pnl, fee, feeToken, net,
51
+ reference, slippageBps: reference && reference > 0 && px && buy != null ? (buy ? px - reference : reference - px) / reference * 10_000 : null,
52
+ closedSize: closed, reason: flags.includes('missing-reason') ? '' : reason, sessionId: fill.sessionId ?? order?.sessionId ?? null, flags, source: payload.mode })
53
+ }
54
+ const reconciled = orders.map((order) => {
55
+ const fills = records.filter((row) => key(row) && key(row) === key(order))
56
+ const filled = fills.reduce((sum, row) => sum + (row.size ?? 0), 0)
57
+ const expected = numeric(order.origSz ?? order.sz), tolerance = Math.max(1e-8, (expected ?? 0) * 1e-6)
58
+ const status = fills.some((row) => row.flags.includes('missing-size')) ? 'missing-size' : /filled/i.test(order.status ?? '') && expected != null && Math.abs(filled - expected) > tolerance ? 'size-gap'
59
+ : expected != null && filled > expected + tolerance ? 'excess-fill' : fills.length ? 'matched' : 'no-fill-in-window'
60
+ return { coin: order.coin, oid: order.oid ?? null, status, filled, expected, exchangeStatus: order.status ?? 'unknown', reason: order.reason ?? '' }
61
+ })
62
+ const supported = records.filter((row) => core(row.coin))
63
+ const fees = {}; for (const row of records) if (row.fee != null && row.feeToken) fees[row.feeToken] = (fees[row.feeToken] ?? 0) + row.fee
64
+ const supportedScope = !coin || core(coin)
65
+ const gross = supportedScope ? supported.reduce((sum, row) => sum + (row.gross ?? 0), 0) : null
66
+ const tradingNet = !supportedScope || supported.some((row) => row.net == null) ? null : supported.reduce((sum, row) => sum + row.net, 0)
67
+ const fundingRows = (payload.funding ?? []).filter((row) => core(row.delta?.coin) && (!coin || row.delta.coin === coin))
68
+ const funding = supportedScope && payload.coverage?.fundingStatus === 'complete' && fundingRows.every((row) => numeric(row.delta.usdc) != null)
69
+ ? fundingRows.reduce((sum, row) => sum + Number(row.delta.usdc), 0) : null
70
+ const closes = supported.filter((row) => row.closedSize > 0)
71
+ return { records, orders: reconciled, duplicates, fees, gross, tradingNet, funding,
72
+ net: tradingNet != null && funding != null ? tradingNet + funding : null,
73
+ closeCount: closes.length,
74
+ missingReasons: records.filter((row) => row.flags.includes('missing-reason')).length,
75
+ unmatched: records.filter((row) => row.flags.includes('unmatched-order')).length,
76
+ gaps: reconciled.filter((row) => ['size-gap', 'excess-fill'].includes(row.status)).length,
77
+ unsupported: records.length - supported.length,
78
+ }
79
+ }
@@ -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
+ }