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/NOTICE +3 -0
- package/README.md +15 -7
- package/client.js +2831 -399
- package/lib/chart-drawings.d.ts +23 -0
- package/lib/chart-drawings.js +159 -0
- package/lib/indicators.js +7 -2
- package/lib/moving-average.d.ts +21 -0
- package/lib/moving-average.js +133 -0
- package/lib/remote.js +133 -32
- package/lib/risk-settings.js +20 -0
- package/lib/store.js +88 -13
- package/lib/tools.js +73 -12
- package/package.json +4 -2
- package/skills/hyperliquid-trading/SKILL.md +11 -1
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
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
if (kind === '
|
|
460
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.#
|
|
226
|
-
this
|
|
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
|
|
232
|
-
const
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
this
|
|
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
|
-
|
|
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)
|