dsh-hyperliquid 0.2.0 → 0.4.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/tools.js CHANGED
@@ -8,10 +8,15 @@
8
8
 
9
9
  import { closedCandles, computeIndicators } from './indicators.js'
10
10
  import { MA_KINDS, MA_SOURCES, maLabel, maLatest, normalizeMaLine, normalizeMaLines } from './moving-average.js'
11
- import { DRAWING_KINDS } from './chart-drawings.js'
11
+ import { INDICATOR_IDS, computeStudies, normalizeIndicatorSettings, studyDefinitions } from './studies.js'
12
+ import { reviewLedger } from './review.js'
13
+ import { DRAWING_KINDS, drawingStyle, volumeProfile } from './chart-drawings.js'
12
14
 
13
- const DRAWING_NAMES = { level: '水平线', hray: '水平射线', vline: '垂直线', text: '文本', trend: '趋势线', ray: '射线', extended: '延长线', channel: '平行通道', fib: '斐波那契回撤', rect: '矩形', measure: '价格区间', long: '多头头寸', short: '空头头寸', fibext: '斐波那契扩展', fibchannel: '斐波那契通道', fibtime: '斐波那契时间周期', gannfan: '江恩扇形', pitchfork: '安德鲁叉子线', regression: '回归趋势', arrow: '箭头', callout: '标注气泡', circle: '圆形', triangle: '三角形', brush: '画笔', crossline: '十字线', pricelabel: '价格标签', infoline: '信息线', trendangle: '趋势角度', pricerange: '价格区间', daterange: '日期区间', elliott: '艾略特推动浪', elliottabc: '艾略特调整浪', xabcd: 'XABCD 谐波', abcd: 'ABCD 形态', headshoulders: '头肩形态', gannbox: '江恩箱', gannsquare: '江恩方格', schiff: 'Schiff 叉子线', modschiff: '改良 Schiff 叉子线', icon: '图标' }
15
+ const DRAWING_NAMES = { vprofile: '固定范围成交量分布', level: '水平线', hray: '水平射线', vline: '垂直线', text: '文本', trend: '趋势线', ray: '射线', extended: '延长线', channel: '平行通道', fib: '斐波那契回撤', rect: '矩形', measure: '价格区间', long: '多头头寸', short: '空头头寸', fibext: '斐波那契扩展', fibchannel: '斐波那契通道', fibtime: '斐波那契时间周期', gannfan: '江恩扇形', pitchfork: '安德鲁叉子线', regression: '回归趋势', arrow: '箭头', callout: '标注气泡', circle: '圆形', triangle: '三角形', brush: '画笔', crossline: '十字线', pricelabel: '价格标签', infoline: '信息线', trendangle: '趋势角度', pricerange: '价格区间', daterange: '日期区间', elliott: '艾略特推动浪', elliottabc: '艾略特调整浪', xabcd: 'XABCD 谐波', abcd: 'ABCD 形态', headshoulders: '头肩形态', gannbox: '江恩箱', gannsquare: '江恩方格', schiff: 'Schiff 叉子线', modschiff: '改良 Schiff 叉子线', icon: '图标' }
14
16
  import { canonicalMarketCoin } from './market-symbol.js'
17
+ import { addEntry, journalDigest, removeStrategy, updateProfile, upsertStrategy } from './journal.js'
18
+ import { analyzeTrading, checkupText } from './analysis.js'
19
+ import { scanMarkets, scanText } from './scan.js'
15
20
 
16
21
  /** Resolve the calling session id from a tool run context. */
17
22
  function sessionIdOf(exec) {
@@ -19,6 +24,8 @@ function sessionIdOf(exec) {
19
24
  return agent?.id ?? agent?.session?.id ?? null
20
25
  }
21
26
 
27
+ const INTERVAL_MS = { '1m': 60e3, '3m': 180e3, '5m': 300e3, '15m': 900e3, '30m': 1800e3, '1h': 3600e3, '2h': 7200e3, '4h': 14400e3, '8h': 28800e3, '12h': 43200e3, '1d': 86400e3, '3d': 259200e3, '1w': 604800e3, '1M': 2592000e3 }
28
+
22
29
  function fmt(n, d = 2) {
23
30
  if (n == null) return '—'
24
31
  const v = Number(n)
@@ -35,8 +42,8 @@ export function losslessJson(value) {
35
42
  return JSON.parse(JSON.stringify(value, (_key, item) => typeof item === 'number' && !Number.isFinite(item) ? null : item))
36
43
  }
37
44
 
38
- export function buildToolDefinitions(remote) {
39
- return buildTools(remote).map((tool) => ({ ...tool, async execute(...args) { return losslessJson(await tool.execute(...args)) } }))
45
+ export function buildToolDefinitions(remote, authorize = () => {}) {
46
+ return buildTools(remote).map((tool) => ({ ...tool, async execute(...args) { authorize(tool.name, args[0], args[1]); return losslessJson(await tool.execute(...args)) } }))
40
47
  }
41
48
 
42
49
  function buildTools(remote) {
@@ -64,15 +71,19 @@ function buildTools(remote) {
64
71
  const bid = snap.book?.levels?.[0]?.[0]
65
72
  const ask = snap.book?.levels?.[1]?.[0]
66
73
  const candles = candleResult.candles.slice(-24)
67
- const tradable = Boolean(coreMarket)
74
+ // Core perps and USDC-collateral HIP-3 perps are tradable; spot and outcomes stay view only.
75
+ const tradable = Boolean(coreMarket) || (detail?.market?.kind === 'hip3' && detail.market.tradable === true)
68
76
  const position = account.positions.find((p) => p.coin === coin) ?? null
69
- const mid = Number(snap.mids?.mids?.[coin] ?? snap.mids?.[coin])
70
- const midFresh = tradable && snap.observedAt?.mids && Date.now() - snap.observedAt.mids < 10_000 && Number.isFinite(mid) && mid > 0
77
+ // HIP-3 mids are not in the core allMids snapshot: read the market's own dex.
78
+ const dexMid = tradable && !coreMarket ? await remote.midOf(coin).then((value) => ({ value, at: Date.now() }), () => null) : null
79
+ const mid = dexMid ? dexMid.value : Number(snap.mids?.mids?.[coin] ?? snap.mids?.[coin])
80
+ const midsAt = dexMid ? dexMid.at : snap.observedAt?.mids
81
+ const midFresh = tradable && midsAt && Date.now() - midsAt < 10_000 && Number.isFinite(mid) && mid > 0
71
82
  const mark = market.ctx?.markPx != null ? Number(market.ctx.markPx) : null
72
83
  const change24hPct = Number(market.ctx?.prevDayPx) > 0 && mark != null ? (mark / Number(market.ctx.prevDayPx) - 1) * 100 : null
73
84
  const context = {
74
85
  workspace: workspace ? { interval: workspace.interval, bookGrouping: workspace.bookGrouping,
75
- chartTab: workspace.chartTab, indicators: workspace.indicators, priceScale: workspace.priceScale } : null,
86
+ chartTab: workspace.chartTab, chartType: workspace.chartType ?? 'candles', indicators: workspace.indicators, indicatorSettings: normalizeIndicatorSettings(workspace.indicatorSettings), research: workspace.research ?? null, priceScale: workspace.priceScale } : null,
76
87
  sessionId, coin, interval, marketKind: detail?.market?.kind ?? 'perp', tradable, network: markets.network, mode: account.mode,
77
88
  mark, mid: midFresh ? mid : null, change24hPct, funding1h: market.ctx?.funding != null ? Number(market.ctx.funding) : null,
78
89
  volume24hUsd: market.ctx?.dayNtlVlm != null ? Number(market.ctx.dayNtlVlm) : null,
@@ -89,8 +100,8 @@ function buildTools(remote) {
89
100
  position, positions: account.positions, openOrders: (account.openOrders ?? []).slice(0, 20), candles,
90
101
  recentTrades: (Array.isArray(snap.trades) ? snap.trades : []).slice(0, 10),
91
102
  bookDepth: { bids: (snap.book?.levels?.[0] ?? []).slice(0, 5), asks: (snap.book?.levels?.[1] ?? []).slice(0, 5) },
92
- riskLimits: status.riskLimits, autonomousLive: status.autonomousLive, walletConfigured: status.walletConfigured,
93
- observedAt: { ...snap.observedAt, market: detail?.observedAt ?? markets.observedAt ?? null }, ws: snap.ws, assembledAt: Date.now(),
103
+ riskLimits: status.riskLimits, autonomousLive: status.autonomousLive, custody: status.custody, walletConfigured: status.walletConfigured,
104
+ observedAt: { ...snap.observedAt, ...(dexMid ? { mids: dexMid.at } : {}), market: detail?.observedAt ?? markets.observedAt ?? null }, ws: snap.ws, assembledAt: Date.now(),
94
105
  }
95
106
  const bookAge = context.observedAt?.book ? Math.round((context.assembledAt - context.observedAt.book) / 1000) : null
96
107
  const midsAge = context.observedAt?.mids ? Math.round((context.assembledAt - context.observedAt.mids) / 1000) : null
@@ -100,14 +111,14 @@ function buildTools(remote) {
100
111
  : bookAge == null || marketAge == null || bookAge > 10 || marketAge > 60 ? '只读行情过期/缺失' : '只读行情可用'
101
112
  const text = [
102
113
  `${coin} ${interval} · ${detail?.market?.kind ?? 'perp'}${tradable ? '' : '(只读市场)'} · ${account.mode}/${markets.network} · 读取 ${new Date(context.assembledAt).toISOString()} · 盘口 ${bookAge ?? '?'}s / ${tradable ? 'mid ' + (midsAge ?? '?') : '市场上下文 ' + (marketAge ?? '?')}s · ${freshness}`,
103
- workspace ? `会话图表:${workspace.chartTab} · 指标=${workspace.indicators.join(',') || '无'} · 刻度=${workspace.priceScale} · 盘口精度=${workspace.bookGrouping}` : '',
114
+ workspace ? `会话图表:${workspace.chartTab}/${workspace.chartType ?? 'candles'} · 指标=${workspace.indicators.join(',') || '无'} · 刻度=${workspace.priceScale} · 盘口精度=${workspace.bookGrouping}` : '',
104
115
  `mark=${fmt(mark)} mid=${fmt(context.mid)} 24h=${change24hPct == null ? '?' : fmt(change24hPct) + '%'} 资金费=${context.funding1h == null ? '不适用' : fmt(context.funding1h * 100, 4) + '%/h'} 24h成交=${fmt(context.volume24hUsd, 0)} USD`,
105
116
  `bid=${fmt(context.bid?.px)} ask=${fmt(context.ask?.px)} ${context.balanceSource === 'spot' ? 'USDC余额' : context.accountDex ? `${context.accountDex} DEX 抵押额` : '权益'}=${fmt(context.accountValue)} ${context.balanceSource === 'spot' ? '交易所可交易名义额' : '可用'}=${fmt(context.available)} ${context.accountDex && context.balanceSource !== 'spot' ? '抵押币单位' : 'USD'} · 挂单=${context.openOrders.length}${context.accountMode ? ` · 账户模式=${context.accountMode}` : ''}`,
106
117
  !tradable ? account.mode === 'paper'
107
118
  ? '当前账户数据不代表该只读市场的余额、仓位或可交易额度。'
108
119
  : `当前市场仅可分析;${context.positionsCoverage === 'all-dexs' ? '仓位来自全部永续 DEX 快照' : '账户仓位仅覆盖所选 DEX'}${position ? `;当前仓位=${fmt(position.size, 6)} ${coin}` : ''},不能据此在该市场下单。`
109
120
  : position ? `仓位 ${position.size > 0 ? 'LONG' : 'SHORT'} ${fmt(Math.abs(position.size), 6)} ${coin} entry=${fmt(position.entryPx)} uPnL=${fmt(position.uPnl)}` : '当前币对无持仓',
110
- `风险:允许币对=${(context.riskLimits?.allowedCoins ?? []).join(',')} 单笔≤${fmt(context.riskLimits?.maxOrderUsd)} USD 每日≤${fmt(context.riskLimits?.maxDailyUsd)} USD 杠杆≤${fmt(context.riskLimits?.maxLeverage, 0)}x · 自主执行=${context.autonomousLive ? '开启' : '关闭'}${account.mode === 'paper' ? ' · 模拟盘固定滑点,未模拟盘口深度、手续费、资金费或强平' : ''}`,
121
+ `风险:允许币对=${(context.riskLimits?.allowedCoins ?? []).join(',')} 单笔≤${fmt(context.riskLimits?.maxOrderUsd)} USD 每日≤${fmt(context.riskLimits?.maxDailyUsd)} USD 杠杆≤${fmt(context.riskLimits?.maxLeverage, 0)}x · 托管模式=${context.custody === 'managed' ? '全托管(可自主执行、调整限额与划转)' : '半托管(实盘与划转需用户批准,限额由用户修改)'}${account.mode === 'paper' ? ' · 模拟盘配置的滑点与手续费,未模拟盘口深度、资金费或强平' : ''}`,
111
122
  `近 ${candles.length} 根 K 线价格(末根可能未收盘): ${candles.map((c) => fmt(c.c)).join(', ')}`,
112
123
  ].join('\n')
113
124
  return { text, context }
@@ -116,19 +127,20 @@ function buildTools(remote) {
116
127
  {
117
128
  name: 'hyper_market',
118
129
  description:
119
- '查询 Hyperliquid 核心永续、HIP-3、现货与 Outcome 行情。action=overview 列出核心永续的 24h 涨跌/成交/资金费;action=book 查盘口;' +
120
- 'action=catalog 搜索所有市场,支持官方 perpCategories 分类;action=candles 查 K 线(支持 Hyperliquid 官方 1m 至 1M 的 14 种周期);action=indicators 计算 SMA/EMA/RSI/MACD/布林带/ATR/支撑阻力;action=trades 查最近成交。非核心永续目前只读。',
130
+ '查询 Hyperliquid 核心永续、HIP-3、现货与 Outcome 行情。action=scan 市场雷达:扫描约 20 个可交易永续(成交额最大的核心永续 + 前 5 个 HIP-3),给出贴近/突破区间、资金费极值、24h 涨跌、溢价的前几名(用户问“今天有什么机会”时先用它);action=overview 列出核心永续的 24h 涨跌/成交/资金费;action=book 查盘口;' +
131
+ 'action=catalog 搜索所有市场,支持官方 perpCategories 分类;action=candles 查 K 线(支持 Hyperliquid 官方 1m 至 1M 的 14 种周期);action=indicators 计算 SMA/EMA/RSI/MACD/布林带/ATR/Stochastic/ADX/OBV/支撑阻力,遵循当前会话指标参数;action=trades 查最近成交。核心永续与 USDC 保证金的 HIP-3 永续(如 xyz:XYZ100)可交易,现货与 Outcome 目前只读;HIP-3 每个 dex 保证金独立,需要该 dex 有 USDC 或开启统一账户。',
121
132
  parameters: {
122
133
  type: 'object',
123
134
  properties: {
124
- action: { type: 'string', enum: ['overview', 'catalog', 'book', 'candles', 'indicators', 'trades'], description: '行情查询类型' },
135
+ action: { type: 'string', enum: ['overview', 'scan', 'catalog', 'book', 'candles', 'indicators', 'trades'], description: '行情查询类型' },
125
136
  ma: { type: 'array', description: 'action=indicators 可选:额外计算的均线,最多 8 条;省略时返回当前会话图表上已叠加的均线', items: { type: 'object', properties: { kind: { type: 'string', enum: MA_KINDS }, period: { type: 'number' }, source: { type: 'string', enum: MA_SOURCES }, smoothing: { type: 'object', properties: { kind: { type: 'string', enum: MA_KINDS }, period: { type: 'number' } } } }, required: ['kind', 'period'] } },
126
137
  coin: { type: 'string', description: '官方合约名,以 action=catalog 返回的 name 为准,如 BTC 或 xyz:XYZ100;overview 时可省略' },
127
138
  kind: { type: 'string', enum: ['perp', 'hip3', 'spot', 'outcome'], description: 'catalog 市场类型过滤' },
128
139
  category: { type: 'string', description: 'catalog 官方分类过滤,如 crypto、stocks、commodities、indices、fx、rates、preipo;tradfi 聚合这些传统金融标签' },
129
140
  query: { type: 'string', description: 'catalog 名称搜索' },
130
141
  interval: { type: 'string', description: 'K 线周期,默认 1h' },
131
- limit: { type: 'number', description: 'candles/trades 返回条数上限,默认 48' },
142
+ limit: { type: 'number', description: 'candles/trades 返回条数上限,默认 48,candles 最多 200' },
143
+ endMs: { type: 'number', description: 'action=candles 可选:返回这一时刻(epoch 毫秒)之前的 K 线,用于往前翻页取历史;交易所每个周期只保留最近约 5000 根' },
132
144
  },
133
145
  required: ['action'],
134
146
  additionalProperties: false,
@@ -154,6 +166,10 @@ function buildTools(remote) {
154
166
  }))
155
167
  return { text: scored.map((s) => `${s.coin} mark=${fmt(s.mark)} 24h=${s.change24hPct.toFixed(2)}% vol=${fmt(s.vol24hUsd, 0)} funding=${s.fundingPct1h.toFixed(4)}%/h OI=${fmt(s.openInterestUsd, 0)} maxLev=${s.maxLeverage}x`).join('\n'), coins: scored }
156
168
  }
169
+ if (action === 'scan') {
170
+ const result = await scanMarkets(remote)
171
+ return { text: scanText(result), ...result }
172
+ }
157
173
  if (action === 'catalog') {
158
174
  const result = await remote.marketCatalog()
159
175
  const query = String(args?.query ?? '').trim().toLowerCase()
@@ -174,31 +190,40 @@ function buildTools(remote) {
174
190
  return { text: `${coin} 盘口\nbids: ${levels(0).map((l) => `${l.px}×${l.sz}`).join(' | ')}\nasks: ${levels(1).map((l) => `${l.px}×${l.sz}`).join(' | ')}`, book: snap.book }
175
191
  }
176
192
  if (action === 'candles') {
177
- const { candles } = await remote.candles({ coin, interval: args?.interval ?? '1h' })
178
193
  const limit = Math.max(1, Math.min(Number(args?.limit) || 48, 200))
179
- const tail = candles.slice(-limit)
194
+ const iv = args?.interval ?? '1h'
195
+ // Paging back: ask for exactly the window that ends at endMs.
196
+ const endMs = Number(args?.endMs) > 0 ? Number(args.endMs) : null
197
+ const { candles } = await remote.candles(endMs ? { coin, interval: iv, endMs, startMs: endMs - (INTERVAL_MS[iv] ?? 3600e3) * (limit + 1) } : { coin, interval: iv })
198
+ const tail = candles.filter((c) => !endMs || c.t < endMs).slice(-limit)
180
199
  return { text: tail.map((c) => new Date(c.t).toISOString().slice(5, 16) + ` O=${fmt(c.o)} H=${fmt(c.h)} L=${fmt(c.l)} C=${fmt(c.c)} V=${fmt(c.v, 3)}`).join('\n'), coin, interval: args?.interval ?? '1h', candles: tail }
181
200
  }
182
201
  if (action === 'indicators') {
183
202
  const interval = args?.interval ?? '1h'
184
203
  const { candles } = await remote.candles({ coin, interval })
204
+ let session = null
205
+ try { session = sessionIdOf(exec) } catch { /* not a trading session */ }
206
+ const workspace = session && remote.store.getSession?.(session) ? remote.store.getSessionWorkspace(session) : null
207
+ const settings = normalizeIndicatorSettings(workspace?.indicatorSettings)
208
+ const closedWindow = closedCandles(candles, { interval })
209
+ const closed = closedWindow.rows
210
+ const studies = computeStudies(closed, settings)
211
+ const configured = { settings, values: Object.fromEntries(Object.entries(studies).map(([key, rows]) => [key, rows.at(-1)?.time === Math.floor((closed.at(-1)?.t ?? 0) / 1000) ? rows.at(-1).value : null])), candlesUsed: closed.length, observedAt: closed.length ? closedWindow.closeTime(closed.at(-1)) : null }
185
212
  const indicators = computeIndicators(candles, { interval })
186
- if (!indicators.ready) return { text: `${coin} ${interval} 指标数据不足:${indicators.reason};已排除 ${indicators.excludedOpen} 根未收盘 K 线`, indicators }
213
+ if (!indicators.ready) return { text: `${coin} ${interval} 指标数据不足:${indicators.reason};已排除 ${indicators.excludedOpen} 根未收盘 K 线`, indicators, configured }
187
214
  const ageMin = Math.max(0, Math.round((Date.now() - indicators.observedAt) / 60_000))
188
215
  const text = [
189
216
  `${coin} ${interval} · ${indicators.candlesUsed} 根已收盘 K 线 · 最新收盘 ${new Date(indicators.observedAt).toISOString()}(${ageMin} 分钟前) · 排除 ${indicators.excludedOpen} 根形成中 K 线`,
190
217
  `收盘=${fmt(indicators.close)} SMA20=${fmt(indicators.sma20)} EMA20=${fmt(indicators.ema20)} EMA50=${fmt(indicators.ema50)}`,
191
- `RSI14=${fmt(indicators.rsi14, 1)} MACD=${fmt(indicators.macd.line, 4)} signal=${fmt(indicators.macd.signal, 4)} hist=${fmt(indicators.macd.histogram, 4)}`,
192
- `布林带下/中/上=${fmt(indicators.bollinger20.lower)}/${fmt(indicators.bollinger20.middle)}/${fmt(indicators.bollinger20.upper)} ATR14=${fmt(indicators.atr14)} 量比20=${fmt(indicators.volumeRatio20, 2)}`,
218
+ '会话参数(已收盘):' + studyDefinitions(settings).filter(([id]) => ['rsi14', 'macd', 'bollinger20', 'stochastic', 'adx', 'obv'].includes(id)).map(([, key, label]) => `${label}=${configured.values[key] == null ? '数据不足' : fmt(configured.values[key], 4)}`).join(' · '),
219
+ `ATR14=${fmt(indicators.atr14)} 量比20=${fmt(indicators.volumeRatio20, 2)}`,
193
220
  `邻近支撑=${fmt(indicators.support.price)}(${new Date(indicators.support.time).toISOString()}) 阻力=${fmt(indicators.resistance.price)}(${new Date(indicators.resistance.time).toISOString()})`,
194
221
  ]
195
- let session = null
196
- try { session = sessionIdOf(exec) } catch { /* not a trading session */ }
197
222
  const requested = Array.isArray(args?.ma) ? normalizeMaLines(args.ma) : (session && remote.store.getSession?.(session) ? remote.store.getSessionWorkspace(session).maLines ?? [] : [])
198
223
  const ma = requested.length ? maLatest(closedCandles(candles, { interval }).rows, requested) : []
199
224
  if (ma.length) text.push('均线(最新已收盘):' + ma.map((row) => `${row.label}=${row.value == null ? '数据不足' : fmt(row.value)}${row.line.smoothing ? `→平滑${fmt(row.smoothed)}` : ''}`).join(' '))
200
225
  text.push('指标是历史 K 线的派生值;需要结合最新盘口、账户风险和数据时效验证,不能单独当作下单信号。')
201
- return { text: text.join('\n'), indicators, ma, interval, coin }
226
+ return { text: text.join('\n'), configured, indicators: { ...indicators, rsi14: configured.values.rsi14, macd: { line: configured.values.macd, signal: configured.values.signal, histogram: configured.values.histogram }, bollinger20: { lower: configured.values.lower, middle: configured.values.basis, upper: configured.values.upper }, parameterSettings: settings }, ma, interval, coin }
202
227
  }
203
228
  if (action === 'trades') {
204
229
  const snap = await remote.snapshot({ coin })
@@ -210,10 +235,12 @@ function buildTools(remote) {
210
235
  },
211
236
  {
212
237
  name: 'hyper_chart',
213
- description: '在当前 DSH 交易会话的行情图上画或管理供人查看的水平支撑/阻力线、趋势线与均线。kind=ma 在图上叠加一条按 K 线实时计算的均线:maType 支持 sma/ema/smma(平滑均线,即 RMA/Wilder)/wma,period 1-500,source 默认 close,可选 smoothingType+smoothingPeriod 再对均线做一次平滑(同 TradingView 均线指标的 Smoothing);均线按会话保存,随周期切换重算,最多 8 条,add 会返回最新已收盘值。画线工具:level 水平线(price)、trend 趋势线(fromTime/fromPrice/toTime/toPrice),其余用 points=[{time,price}]:hray 水平射线/vline 垂直线 1 点,ray 射线/extended 延长线/fib 斐波那契回撤(第 1 点=1、第 2 点=0)/rect 矩形/measure 价格区间 2 点,channel 平行通道 3 点(第 3 点定通道宽度),text 文本 1 点(label 为文本,最多 200 字),long/short 多空头寸 3 点 [入场, 止损, 止盈](第 2 点时间为右边界);fibext 斐波那契扩展 3 点(A→B 波段,自 C 投射)、fibchannel 斐波那契通道 3 点、fibtime 斐波那契时间周期 2 点、gannfan 江恩扇形 2 点(第 2 点定 1×1)、pitchfork 安德鲁叉子线 3 点、regression 回归趋势 2 点(时间区间)、arrow 箭头 2 点、callout 标注气泡 2 点(锚点+文本框位置,label 为文本)、circle 圆形 2 点(圆心+边缘)、triangle 三角形 3 点、brush 画笔 2-500 点;crossline 十字线、pricelabel 价格标签 1 点;infoline 信息线、trendangle 趋势角度、pricerange 价格区间、daterange 日期区间 2 点;elliott 推动浪 6 点(0-5)、elliottabc 调整浪 4 点(0-A-B-C)、xabcd 谐波 5 点、abcd 4 点、headshoulders 头肩 7 点(左底-左肩-颈1-头-颈2-右肩-右底)、gannbox/gannsquare 2 点对角、schiff/modschiff 叉子线 3 点、icon 图标 1 点(label 为 flag/star/up/down/warning/check/cross/fire/target/bell,或贴图库 emoji:📈/🚀/🐂/🐻/⚠️ 等)。update 按 id 修改 points/price/label 或样式 color/lineWidth(1-4)/lineStyle(solid/dashed/dotted)/locked。画线按会话和标的保存,在该标的所有周期显示。先用 hyper_market candles/indicators 验证价格和时间;标注仅用于解释,不是自动订单或交易信号。',
238
+ description: '在当前 DSH 交易会话的行情图上画或管理供人查看的水平支撑/阻力线、趋势线与均线,并切换图表周期与副图指标。action=view 设置周期(interval)并用 show/hide 开关指标(rsi14、macd、bollinger20、volume、atr14、vwap、stochastic、adx、obv 等,图上实时计算,不要自己算数值再画线)。kind=ma 在图上叠加一条按 K 线实时计算的均线:maType 支持 sma/ema/smma(平滑均线,即 RMA/Wilder)/wma,period 1-500,source 默认 close,可选 smoothingType+smoothingPeriod 再对均线做一次平滑(同 TradingView 均线指标的 Smoothing);均线按会话保存,随周期切换重算,最多 8 条,add 会返回最新已收盘值。画线工具:level 水平线(price)、trend 趋势线(fromTime/fromPrice/toTime/toPrice),其余用 points=[{time,price}]:hray 水平射线/vline 垂直线 1 点,ray 射线/extended 延长线/fib 斐波那契回撤(第 1 点=1、第 2 点=0)/rect 矩形/measure 价格区间 2 点,vprofile 固定范围成交量分布 2 点(只取两点的时间区间,返回 POC 与 70% 价值区 VAH/VAL),channel 平行通道 3 点(第 3 点定通道宽度),text 文本 1 点(label 为文本,最多 200 字),long/short 多空头寸 3 点 [入场, 止损, 止盈](第 2 点时间为右边界);fibext 斐波那契扩展 3 点(A→B 波段,自 C 投射)、fibchannel 斐波那契通道 3 点、fibtime 斐波那契时间周期 2 点、gannfan 江恩扇形 2 点(第 2 点定 1×1)、pitchfork 安德鲁叉子线 3 点、regression 回归趋势 2 点(时间区间)、arrow 箭头 2 点、callout 标注气泡 2 点(锚点+文本框位置,label 为文本)、circle 圆形 2 点(圆心+边缘)、triangle 三角形 3 点、brush 画笔 2-500 点;crossline 十字线、pricelabel 价格标签 1 点;infoline 信息线、trendangle 趋势角度、pricerange 价格区间、daterange 日期区间 2 点;elliott 推动浪 6 点(0-5)、elliottabc 调整浪 4 点(0-A-B-C)、xabcd 谐波 5 点、abcd 4 点、headshoulders 头肩 7 点(左底-左肩-颈1-头-颈2-右肩-右底)、gannbox/gannsquare 2 点对角、schiff/modschiff 叉子线 3 点、icon 图标 1 点(label 为 flag/star/up/down/warning/check/cross/fire/target/bell,或贴图库 emoji:📈/🚀/🐂/🐻/⚠️ 等)。update 按 id 修改 points/price/label 或样式 color/lineWidth(1-4)/lineStyle(solid/dashed/dotted)/locked。画线按会话和标的保存,在该标的所有周期显示。先用 hyper_market candles/indicators 验证价格和时间;标注仅用于解释,不是自动订单或交易信号。',
214
239
  parameters: {
215
240
  type: 'object', properties: {
216
- action: { type: 'string', enum: ['add', 'list', 'update', 'remove'] },
241
+ action: { type: 'string', enum: ['add', 'list', 'update', 'remove', 'view'], description: 'view:切换图表周期与副图指标' },
242
+ show: { type: 'array', items: { type: 'string', enum: INDICATOR_IDS }, description: 'action=view:要显示的指标' },
243
+ hide: { type: 'array', items: { type: 'string', enum: INDICATOR_IDS }, description: 'action=view:要隐藏的指标' },
217
244
  coin: { type: 'string', description: '默认会话绑定币对' },
218
245
  interval: { type: 'string', description: '默认当前交易台周期' },
219
246
  kind: { type: 'string', enum: [...DRAWING_KINDS, 'ma'], description: 'add 必填:画线类型或 ma 均线' },
@@ -225,6 +252,9 @@ function buildTools(remote) {
225
252
  smoothingPeriod: { type: 'number', description: 'kind=ma 可选:平滑周期' },
226
253
  color: { type: 'string', description: '可选 #RRGGBB 颜色(画线或均线)' },
227
254
  lineWidth: { type: 'number', description: '画线线宽 1-4' },
255
+ levels: { type: 'array', items: { type: 'number' }, description: 'kind=fib/fibext/fibchannel:自定义斐波那契层级,如 [0,0.382,0.5,0.618,1,1.618]' },
256
+ rows: { type: 'number', description: 'kind=vprofile:价格行数 6-200,默认 48' },
257
+ valueArea: { type: 'number', description: 'kind=vprofile:价值区成交量占比 0.5-0.95,默认 0.7' },
228
258
  lineStyle: { type: 'string', enum: ['solid', 'dashed', 'dotted'], description: '画线线型' },
229
259
  locked: { type: 'boolean', description: 'update:锁定后用户不能误拖动' },
230
260
  hidden: { type: 'boolean', description: 'update:在图上隐藏(保留在画线列表中)' },
@@ -259,6 +289,10 @@ function buildTools(remote) {
259
289
  const lines = [...result.marks.map((mark) => `${mark.id} ${mark.interval} ${mark.kind} ${mark.role} ${mark.label}`), ...ma.map((row) => `${maText(row)}(${coin} ${interval} 最新已收盘)`)]
260
290
  return { text: lines.length ? lines.join('\n') : '当前标的没有标注或均线', ...result, maLines: ma }
261
291
  }
292
+ if (args?.action === 'view') {
293
+ const view = remote.setSessionChartView(sessionId, { interval: args?.interval, show: args?.show ?? [], hide: args?.hide ?? [] })
294
+ return { text: `图表周期 ${view.interval},显示指标:${view.indicators.join(', ') || '无'}`, interval: view.interval, indicators: view.indicators }
295
+ }
262
296
  if (args?.action === 'add' && args?.kind === 'ma') {
263
297
  const line = normalizeMaLine({ kind: args.maType, period: args.period, source: args.source, color: args.color,
264
298
  smoothing: args.smoothingType != null || args.smoothingPeriod != null ? { kind: args.smoothingType, period: args.smoothingPeriod } : null })
@@ -272,13 +306,33 @@ function buildTools(remote) {
272
306
  return { text: `已移除均线:${maLabel(removed)}(${removed.id})`, line: removed }
273
307
  }
274
308
  if (args?.action === 'update') {
275
- const patch = Object.fromEntries(['points', 'price', 'fromTime', 'fromPrice', 'toTime', 'toPrice', 'label', 'color', 'lineWidth', 'lineStyle', 'locked', 'hidden', 'shared', 'alert'].filter((key) => args?.[key] != null).map((key) => [key, args[key]]))
309
+ const patch = Object.fromEntries(['points', 'price', 'fromTime', 'fromPrice', 'toTime', 'toPrice', 'label', 'color', 'lineWidth', 'lineStyle', 'locked', 'hidden', 'shared', 'alert', 'rows', 'valueArea', 'levels'].filter((key) => args?.[key] != null).map((key) => [key, args[key]]))
276
310
  const mark = remote.editChartMark(args?.id, patch, sessionId)
277
311
  return { text: `已更新${DRAWING_NAMES[mark.kind] ?? mark.kind}:${mark.label}(${mark.id})`, mark }
278
312
  }
279
313
  if (args?.action === 'add') {
314
+ // Fetch the range first: a failed market request must not leave a saved drawing behind.
315
+ let summary = null
316
+ if (args?.kind === 'vprofile') {
317
+ // Validate rows / valueArea before they size any computation.
318
+ drawingStyle({ kind: 'vprofile', rows: args?.rows, valueArea: args?.valueArea })
319
+ const times = (Array.isArray(args?.points) ? args.points : []).map((point) => Number(point?.time)).filter(Number.isFinite).sort((x, y) => x - y)
320
+ if (times.length === 2) {
321
+ const { candles } = await remote.candles({ coin, interval, startMs: times[0], endMs: times[1] + 1 })
322
+ const inRange = candles.filter((c) => c.t >= times[0] && c.t <= times[1])
323
+ const profile = volumeProfile(inRange.map((c) => ({ open: c.o, high: c.h, low: c.l, close: c.c, volume: c.v })), { rows: Number(args?.rows) || 48, valueArea: Number(args?.valueArea) || 0.7 })
324
+ const step = INTERVAL_MS[interval] ?? (inRange.length > 1 ? inRange[1].t - inRange[0].t : 0)
325
+ const partial = !inRange.length || (step > 0 && inRange[0].t - times[0] >= step)
326
+ summary = { profile, partial, bars: inRange.length, from: inRange[0]?.t ?? null }
327
+ }
328
+ }
280
329
  const mark = remote.addChartMark({ ...args, coin, interval }, sessionId)
281
- return { text: `已在 ${coin} ${interval} 图表添加${DRAWING_NAMES[mark.kind] ?? mark.kind}:${mark.label}(${mark.id})`, mark }
330
+ const text = `已在 ${coin} ${interval} 图表添加${DRAWING_NAMES[mark.kind] ?? mark.kind}:${mark.label}(${mark.id})`
331
+ if (!summary) return { text, mark }
332
+ if (!summary.profile) return { text: `${text};区间内没有成交量数据,无法统计成交量分布`, mark }
333
+ const { profile } = summary
334
+ const note = `按 ${summary.bars} 根 ${interval} K 线估算(每根成交量均匀分摊到其高低区间)${summary.partial ? `;交易所只返回 ${new Date(summary.from).toISOString().slice(0, 16)} 之后的 K 线,区间前段缺失` : ''}`
335
+ return { text: `${text};POC=${fmt(profile.poc)} 价值区 VAH=${fmt(profile.vah)} VAL=${fmt(profile.val)}(${Math.round((Number(args?.valueArea) || 0.7) * 100)}% 成交量);${note}`, mark, profile: { poc: profile.poc, vah: profile.vah, val: profile.val, bars: summary.bars, partial: summary.partial } }
282
336
  }
283
337
  if (args?.action === 'remove') {
284
338
  const mark = remote.removeChartMark(args?.id, sessionId)
@@ -289,19 +343,49 @@ function buildTools(remote) {
289
343
  },
290
344
  {
291
345
  name: 'hyper_account',
292
- description: '查询交易账户。action=state 返回权益/持仓;orders 返回挂单;fills 返回成交;orderStatus 用 oid 或 cloid 核对某笔 live 订单,尤其在网络超时后。',
346
+ description: '查询交易账户。action=state 返回权益/持仓;orders 返回挂单;fills 返回成交;orderStatus 用 oid 或 cloid 核对某笔 live 订单,尤其在网络超时后。review 返回最近 7/30/90 天成交复盘、订单数量核对、费用和资金费覆盖缺口;不是完整账户收益率。' +
347
+ 'action=analyze 交易体检(默认 90 天):按完整往返统计胜率、盈亏比、期望、持仓时长、按币种/时段/星期的表现、手续费与资金费,并给出按金额排序的习惯问题和建议规则——第一次见面或用户问“我做得怎么样”时用它,不要自己从成交里手算。' +
348
+ 'action=limits 修改允许币对(addCoins/removeCoins)与单笔/每日/仓位/杠杆上限——仅全托管模式可用,半托管时由用户在 设置 → 交易限额与签名规则 修改。' +
349
+ 'action=transfer 在主永续账户与 HIP-3 交易所之间划转 USDC(dex,amount,direction=in 转入 / out 转回)——全托管直接执行,半托管每次需用户批准;仅 live。',
293
350
  parameters: {
294
351
  type: 'object',
295
352
  properties: {
296
- action: { type: 'string', enum: ['state', 'orders', 'fills', 'orderStatus'], description: '账户查询类型,默认 state' },
353
+ action: { type: 'string', enum: ['state', 'orders', 'fills', 'orderStatus', 'review', 'analyze', 'limits', 'transfer'], description: '账户动作,默认 state' },
354
+ addCoins: { type: 'array', items: { type: 'string' }, description: 'action=limits:加入允许币对,如 ["xyz:XYZ100"]' },
355
+ removeCoins: { type: 'array', items: { type: 'string' }, description: 'action=limits:移出允许币对' },
356
+ maxOrderUsd: { type: 'number' }, maxDailyUsd: { type: 'number' }, maxPositionUsd: { type: 'number' }, maxLeverage: { type: 'number' },
357
+ dex: { type: 'string', description: 'action=transfer:HIP-3 交易所名,如 xyz' },
358
+ amount: { type: 'number', description: 'action=transfer:USDC 数量' },
359
+ direction: { type: 'string', enum: ['in', 'out'], description: 'action=transfer:in=主账户转入该交易所(默认),out=转回主账户' },
297
360
  oid: { type: 'string', description: 'orderStatus 必填;交易所 oid 或 0x 开头 128 位 cloid' },
361
+ days: { type: 'number', enum: [7, 30, 90], description: 'review 窗口,默认 30 天' },
362
+ coin: { type: 'string', description: 'review 可选,仅核对该标的;省略为全部' },
298
363
  },
299
364
  additionalProperties: false,
300
365
  },
301
366
  output: { schema: { type: 'object', additionalProperties: true }, render: (_a, v) => [{ type: 'text', text: v.text }] },
302
- timeoutMs: 15_000,
303
- async execute(args) {
367
+ timeoutMs: 60_000,
368
+ async execute(args, exec) {
304
369
  const action = args?.action ?? 'state'
370
+ if (action === 'analyze') {
371
+ const days = args?.days ?? 90
372
+ if (![7, 30, 90].includes(days)) throw Error('交易体检支持最近 7/30/90 天')
373
+ const evidence = await remote.tradeHistory({ includeFunding: true, days })
374
+ const fills = args?.coin ? (evidence.fills ?? []).filter((row) => row.coin === canonicalMarketCoin(args.coin)) : evidence.fills
375
+ const funding = evidence.coverage?.fundingStatus === 'complete' ? evidence.funding : null
376
+ const result = analyzeTrading(fills, { funding, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai' })
377
+ const limited = evidence.coverage?.fillsLimited ? '(交易所只返回最近 2000 笔成交,更早的未计入)' : ''
378
+ return { text: checkupText(result, { days, mode: `${evidence.mode}/${evidence.network}` }) + limited, ...result, coverage: { ...result.coverage, ...evidence.coverage } }
379
+ }
380
+ if (action === 'limits') {
381
+ const { before, after } = remote.agentUpdateRiskSettings(args)
382
+ const diff = Object.keys(after).filter((key) => JSON.stringify(before[key]) !== JSON.stringify(after[key]))
383
+ return { text: diff.length ? `已更新交易限额:${diff.map((key) => `${key} ${JSON.stringify(before[key])} → ${JSON.stringify(after[key])}`).join(';')}` : '交易限额未变化', before, after }
384
+ }
385
+ if (action === 'transfer') {
386
+ const res = await remote.transferCollateral(args, exec)
387
+ return { text: `已划转 ${res.amount} USDC:${res.direction === 'in' ? `主永续账户 → ${res.dex}` : `${res.dex} → 主永续账户`}`, ...res }
388
+ }
305
389
  if (action === 'state') {
306
390
  const a = await remote.account({ coin: remote.desk?.coin })
307
391
  const lines = [`mode=${a.mode}${a.address ? ' ' + a.address : ''}${a.accountDex ? ` DEX=${a.accountDex}` : ''}${a.positionsCoverage ? ` positions=${a.positionsCoverage}` : ''}`]
@@ -329,6 +413,13 @@ function buildTools(remote) {
329
413
  const rows = f.fills ?? []
330
414
  return { text: rows.length ? rows.slice(0, 20).map((r) => `${new Date(r.time ?? r.filledTime ?? Date.now()).toISOString().slice(5, 19)} ${r.coin} ${r.side ?? ''} ${fmt(r.sz ?? r.filledSz, 4)} @ ${fmt(r.px ?? r.filledPx)}`).join('\n') : '(无成交)', fills: rows.slice(0, 50) }
331
415
  }
416
+ if (action === 'review') {
417
+ const days = args?.days ?? 30
418
+ if (![7, 30, 90].includes(days)) throw Error('复盘支持最近 7/30/90 天')
419
+ const evidence = await remote.tradeHistory({ includeFunding: true, days })
420
+ const review = reviewLedger(evidence, args?.coin ? canonicalMarketCoin(args.coin) : '')
421
+ return { text: `窗口复盘(不是账户收益率):${JSON.stringify({ coverage: evidence.coverage, mode: evidence.mode, network: evidence.network, ...review })}`, evidence, review }
422
+ }
332
423
  if (action === 'orderStatus') {
333
424
  const result = await remote.orderStatus({ oid: args?.oid })
334
425
  return { text: JSON.stringify(result), ...result }
@@ -338,7 +429,7 @@ function buildTools(remote) {
338
429
  },
339
430
  {
340
431
  name: 'hyper_decide',
341
- description: '对一个 live 交易意图做服务端预检:读取实时价格、账户权益和仓位,执行币对/单笔/每日/仓位/杠杆硬限制,并生成 30 秒、仅本会话可用的一次性 decisionId。可选 takeProfitPx/stopLossPx 会随主单作为 normalTpsl 分组保护单提交;父单未完全成交时保护单尚未挂出,部分成交后撤销父单会撤销子单,必须核对真实仓位与保护挂单。paper 暂不支持触发单。先独立分析行情与风险,再提交方向、数量和依据;预检通过不等于交易所已成交。',
432
+ description: '对一个 live 交易意图做服务端预检:读取实时价格、账户权益和仓位,执行币对/单笔/每日/仓位/杠杆硬限制,并生成 30 秒、仅本会话可用的一次性 decisionId。可选 takeProfitPx/stopLossPx 会随主单作为 normalTpsl 分组保护单提交(目前仅 testnet;mainnet 传入会被拒绝);父单未完全成交时保护单尚未挂出,部分成交后撤销父单会撤销子单,必须核对真实仓位与保护挂单。paper 暂不支持触发单。先独立分析行情与风险,再提交方向、数量和依据;预检通过不等于交易所已成交。',
342
433
  parameters: {
343
434
  type: 'object',
344
435
  properties: {
@@ -361,8 +452,8 @@ function buildTools(remote) {
361
452
  {
362
453
  name: 'hyper_order',
363
454
  description:
364
- '交易下单/撤单/杠杆。action=place:paper 直接执行;live 必须先调用 hyper_decide 并提供同一订单的一次性 decisionId。live 可带 takeProfitPx/stopLossPx,必须与预检完全一致,作为 normalTpsl 分组保护单提交;父单未完全成交时保护单尚未挂出,部分成交后撤销父单会撤销子单,提交后须核对仓位与实际保护挂单。自主 live 仅在宿主显式开启且钱包策略允许时执行,否则触发人工审批。' +
365
- 'action=cancel 撤单(oid);action=leverage 设置杠杆(1-50)。autonomousLive=false 时 live 撤单和杠杆变更需人工审批;否则仍受 dsh-web3 钱包策略约束。',
455
+ '交易下单/撤单/杠杆。action=place:paper 直接执行;live 必须先调用 hyper_decide 并提供同一订单的一次性 decisionId。live 可带 takeProfitPx/stopLossPx(目前仅 testnet),必须与预检完全一致,作为 normalTpsl 分组保护单提交;父单未完全成交时保护单尚未挂出,部分成交后撤销父单会撤销子单,提交后须核对仓位与实际保护挂单。全托管模式下 live 直接执行(仍受钱包策略约束),半托管触发人工审批。' +
456
+ 'action=cancel 撤单(oid);action=leverage 设置杠杆(1-50)。半托管时 live 下单、撤单和杠杆变更需人工审批;全托管时直接执行,但仍受 dsh-web3 钱包策略约束。',
366
457
  parameters: {
367
458
  type: 'object',
368
459
  properties: {
@@ -468,5 +559,70 @@ function buildTools(remote) {
468
559
  return { text: lines.join('\n'), session: row, status: st }
469
560
  },
470
561
  },
562
+ {
563
+ name: 'hyper_journal',
564
+ description:
565
+ '交易员日志(跨会话、重启保留,摘要会自动出现在每个会话的系统提示里)。action=read 读取交易员档案、策略库(含按 paper/live 分开统计的笔数、胜率、期望 R)、最近教训与记录;' +
566
+ 'action=profile 用 fields 更新档案(如 {"风险偏好":"单笔最多亏 1%","持仓周期":"日内到 3 天","打扰偏好":"只在需要决策时提醒"},null 删除字段);' +
567
+ 'action=strategy 新建或修订策略(不传 id 为新建,至少 name+thesis;修订必须传 why 说明改动原因,版本号自动递增),action=removeStrategy 删除;' +
568
+ 'action=log 追加记录:type=plan 交易计划 / trade 已平仓结果(必须 resultR=盈亏÷计划风险)/ review 复盘 / lesson 可复用的教训 / note 其他,可关联 strategyId 与 coin。' +
569
+ '用户表达偏好、纠正你、或一笔交易结束时就写入,不要等用户要求。',
570
+ parameters: {
571
+ type: 'object',
572
+ properties: {
573
+ action: { type: 'string', enum: ['read', 'profile', 'strategy', 'removeStrategy', 'log'], description: '默认 read' },
574
+ fields: { type: 'object', additionalProperties: true, description: 'action=profile:{字段: 内容 | null}' },
575
+ id: { type: 'string', description: 'strategy 修订 / removeStrategy 的策略 id' },
576
+ name: { type: 'string' }, markets: { type: 'string', description: '适用市场,如 BTC,ETH,xyz:XYZ100' }, timeframe: { type: 'string', description: '周期,如 4h 结构 + 15m 入场' },
577
+ thesis: { type: 'string', description: '为什么能赚钱:市场行为与优势来源' }, entry: { type: 'string', description: '入场条件(可核对的)' },
578
+ exit: { type: 'string', description: '止盈与离场规则' }, invalidation: { type: 'string', description: '止损与作废条件' },
579
+ sizing: { type: 'string', description: '仓位与风险规则,如 单笔风险 0.5% 权益' }, notes: { type: 'string' },
580
+ status: { type: 'string', enum: ['idea', 'testing', 'active', 'paused', 'retired'] },
581
+ why: { type: 'string', description: '修订策略时必填:依据哪些交易或复盘做了什么改动' },
582
+ type: { type: 'string', enum: ['plan', 'trade', 'review', 'lesson', 'note'], description: 'action=log 的记录类型' },
583
+ text: { type: 'string', description: 'action=log 的内容' }, coin: { type: 'string' }, strategyId: { type: 'string' },
584
+ strategyVersion: { type: 'integer', minimum: 1, description: 'action=log:默认当前策略版本;修订前的交易平仓时填原版本,战绩按版本分别统计' },
585
+ resultR: { type: 'number', description: 'type=trade:结果的 R 倍数(亏损为负)' }, pnlUsd: { type: 'number', description: 'type=trade:可选,实际盈亏 USD' },
586
+ },
587
+ additionalProperties: false,
588
+ },
589
+ output: { schema: { type: 'object', additionalProperties: true }, render: (_a, v) => [{ type: 'text', text: v.text }] },
590
+ timeoutMs: 10_000,
591
+ async execute(args, exec) {
592
+ const action = args?.action ?? 'read'
593
+ let sessionId = null
594
+ try { sessionId = sessionIdOf(exec) } catch { /* journal is not bound to a trading session */ }
595
+ let journal = remote.store.journal()
596
+ if (action === 'read') {
597
+ const digest = journalDigest(journal)
598
+ const text = [
599
+ Object.keys(digest.profile).length ? '档案:' + Object.entries(digest.profile).map(([k, v]) => `${k}=${v}`).join(';') : '档案:空(与用户交流时逐步记录偏好)',
600
+ digest.strategies.length ? '策略:\n' + digest.strategies.map((row) => `[${row.id}] ${row.name} ${row.status} v${row.version} ${row.markets ?? ''} ${row.timeframe ?? ''}\n 依据:${row.thesis}${row.entry ? `\n 入场:${row.entry}` : ''}${row.exit ? `\n 离场:${row.exit}` : ''}${row.invalidation ? `\n 作废:${row.invalidation}` : ''}${row.sizing ? `\n 仓位:${row.sizing}` : ''}\n 战绩:${Object.entries(row.stats).map(([mode, s]) => `${mode} ${s.trades}笔 胜率${Math.round(s.winRate * 100)}% 期望${fmt(s.expectancyR, 2)}R 最差${fmt(s.worstR, 2)}R`).join(';') || '暂无已平仓记录'}`).join('\n') : '策略:空',
601
+ digest.lessons.length ? '教训:' + digest.lessons.map((entry) => entry.text).join(';') : '',
602
+ digest.recent.length ? '最近记录:\n' + digest.recent.map((entry) => `${new Date(entry.at).toISOString().slice(0, 16)} ${entry.type}${entry.coin ? ' ' + entry.coin : ''}${entry.strategyId ? ' [' + entry.strategyId + ']' : ''}${entry.resultR != null ? ' ' + fmt(entry.resultR, 2) + 'R' : ''} ${entry.text.slice(0, 160)}`).join('\n') : '',
603
+ ].filter(Boolean).join('\n')
604
+ return { text, ...digest }
605
+ }
606
+ if (action === 'profile') {
607
+ journal = remote.store.saveJournal(updateProfile(journal, args?.fields))
608
+ return { text: `已更新交易员档案:${Object.keys(args?.fields ?? {}).join('、')}`, profile: journalDigest(journal).profile }
609
+ }
610
+ if (action === 'strategy') {
611
+ const result = upsertStrategy(journal, args)
612
+ remote.store.saveJournal(result.journal)
613
+ return { text: `${result.strategy.version === 1 ? '已新建' : '已修订'}策略 [${result.strategy.id}] ${result.strategy.name}(${result.strategy.status},v${result.strategy.version})`, strategy: result.strategy }
614
+ }
615
+ if (action === 'removeStrategy') {
616
+ remote.store.saveJournal(removeStrategy(journal, String(args?.id ?? '')))
617
+ return { text: `已删除策略 ${args?.id}` }
618
+ }
619
+ if (action === 'log') {
620
+ const result = addEntry(journal, args, { mode: remote.cfg.tradingMode, network: remote.cfg.network, sessionId: sessionId ?? undefined })
621
+ remote.store.saveJournal(result.journal)
622
+ return { text: `已记录 ${result.entry.type}${result.entry.resultR != null ? ` ${fmt(result.entry.resultR, 2)}R` : ''}`, entry: result.entry }
623
+ }
624
+ throw new Error(`未知 action: ${action}`)
625
+ },
626
+ },
471
627
  ]
472
628
  }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Track who started the current turn, so an automatic alert can never authorize money operations
3
+ * (orders, pre-checks, limit changes, transfers).
4
+ *
5
+ * In memory: `turn/start` opens a turn; an event message from this plugin marks it as an alert turn.
6
+ * On disk (`origin.get/set`): the session's last known origin — set to "alert" before the waker delivers,
7
+ * cleared only when a human message is seen. A turn the guard did not observe (plugin reloaded mid-turn)
8
+ * falls back to that record, so a reload never turns an alert turn into an authorised one, however long
9
+ * it has been; the trader sending a new message is what clears it.
10
+ */
11
+
12
+ const isPluginEvent = (source) => source?.kind === 'plugin:dsh-hyperliquid' && source.form === 'event'
13
+ const sessionIdOf = (session) => session?.id ?? null
14
+
15
+ /**
16
+ * @param ctx - host context (`session/event`).
17
+ * @param options.origin - persisted `{ get(sessionId): boolean | undefined, set(sessionId, alert: boolean) }`.
18
+ */
19
+ export function createTurnOriginGuard(ctx, { origin = { get: () => undefined, set: () => {} } } = {}) {
20
+ const turns = new WeakMap()
21
+ ctx.on('session/event', (session, event) => {
22
+ // A turn is trusted only once a human message is seen in it; until then the persisted origin decides.
23
+ if (event.type === 'turn/start') turns.set(session, { alert: false, human: false })
24
+ if (event.type !== 'user/message') return
25
+ const source = event.data?.source
26
+ if (isPluginEvent(source)) {
27
+ turns.set(session, { alert: true, human: false })
28
+ if (sessionIdOf(session)) origin.set(sessionIdOf(session), true)
29
+ } else if (source?.kind === 'user' && !turns.get(session)?.alert) {
30
+ // A human instruction makes this a trusted turn and retires the persisted alert origin.
31
+ turns.set(session, { alert: false, human: true })
32
+ if (sessionIdOf(session)) origin.set(sessionIdOf(session), false)
33
+ }
34
+ })
35
+ return (tool, args, exec) => {
36
+ const money = tool === 'hyper_order' || tool === 'hyper_decide' || (tool === 'hyper_account' && ['limits', 'transfer'].includes(args?.action))
37
+ if (!money) return
38
+ const turn = turns.get(exec?.agent?.session)
39
+ if (turn?.alert) throw new Error('交易台自动通知不能授权下单、改限额或划转;请等待用户的新指令')
40
+ if (turn?.human) return
41
+ // No human message seen in this turn (not observed at all, only turn/start, or a scheduled turn):
42
+ // the persisted origin decides until a human message clears it.
43
+ const sessionId = exec?.agent?.id ?? sessionIdOf(exec?.agent?.session)
44
+ if (sessionId && origin.get(sessionId) === true) throw new Error('无法确认本轮是否来自自动通知,资金操作已阻止;请用户直接发一条新指令')
45
+ }
46
+ }
package/lib/wake.js ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * dsh-hyperliquid — wake the trading session's agent when something it should know about happens
3
+ * (a price alert on one of its lines). v1 is notify-only: the message asks the agent to re-check and
4
+ * tell the trader; it is explicitly not an instruction or an authorisation to trade.
5
+ *
6
+ * Guard rails (codex review): each event is delivered once, events within a short window are merged,
7
+ * a session is woken at most once per cooldown (later events wait for the next slot and are merged),
8
+ * stale events are dropped, and deliveries for one session are serial.
9
+ * @module lib/wake
10
+ */
11
+
12
+ const DEFAULTS = { debounceMs: 5_000, cooldownMs: 5 * 60_000, staleMs: 10 * 60_000 }
13
+
14
+ /**
15
+ * @param {{ deliver: (sessionId: string, text: string) => Promise<boolean>, describe?: (events: object[]) => string,
16
+ * now?: () => number, setTimer?: typeof setTimeout, clearTimer?: typeof clearTimeout, debounceMs?: number, cooldownMs?: number, staleMs?: number }} options
17
+ */
18
+ export function createAgentWaker(options) {
19
+ const { deliver, describe = alertWakeText, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout } = options
20
+ const { debounceMs, cooldownMs, staleMs } = { ...DEFAULTS, ...options }
21
+ let disposed = false
22
+ const controller = new AbortController()
23
+ const seen = new Set()
24
+ const sessions = new Map() // sessionId → { pending: event[], timer, lastAt, busy }
25
+ const state = (id) => { if (!sessions.has(id)) sessions.set(id, { pending: [], timer: null, lastAt: -Infinity, busy: false }); return sessions.get(id) }
26
+
27
+ const flush = async (sessionId) => {
28
+ if (disposed) return
29
+ const s = state(sessionId)
30
+ s.timer = null
31
+ if (s.busy) { schedule(sessionId, debounceMs); return }
32
+ // A timer set while the previous delivery was in flight may fire early: honour the cooldown here.
33
+ const wait = s.lastAt + cooldownMs - now()
34
+ if (wait > 0) { schedule(sessionId, wait); return }
35
+ const fresh = s.pending.filter((event) => now() - event.at <= staleMs)
36
+ s.pending = []
37
+ if (!fresh.length) return
38
+ s.busy = true
39
+ try {
40
+ if (await deliver(sessionId, describe(fresh), { signal: controller.signal })) s.lastAt = now()
41
+ } catch { /* the desk still shows the alert; never retry into a loop */ }
42
+ finally { s.busy = false }
43
+ if (s.pending.length) schedule(sessionId, Math.max(debounceMs, s.lastAt + cooldownMs - now()))
44
+ }
45
+ const schedule = (sessionId, delay) => {
46
+ if (disposed) return
47
+ const s = state(sessionId)
48
+ if (s.timer) return
49
+ s.timer = setTimer(() => { void flush(sessionId) }, Math.max(0, delay))
50
+ s.timer?.unref?.()
51
+ }
52
+
53
+ return {
54
+ /** Queue one event (`{ id, at, sessionId, ... }`); returns false when it was a duplicate or unroutable. */
55
+ notify(event) {
56
+ if (disposed || !event?.sessionId || !event.id) return false
57
+ const key = `${event.id}:${event.at}`
58
+ if (seen.has(key)) return false
59
+ seen.add(key)
60
+ if (seen.size > 5000) seen.delete(seen.values().next().value)
61
+ const s = state(event.sessionId)
62
+ s.pending.push(event)
63
+ schedule(event.sessionId, Math.max(debounceMs, s.lastAt + cooldownMs - now()))
64
+ return true
65
+ },
66
+ /** Last delivery time for a session (the turn-origin guard uses it when a turn was not observed). */
67
+ wokenAt(sessionId) { const s = sessions.get(sessionId); return s && Number.isFinite(s.lastAt) ? s.lastAt : undefined },
68
+ dispose() { disposed = true; controller.abort(); for (const s of sessions.values()) if (s.timer) clearTimer(s.timer); sessions.clear() },
69
+ }
70
+ }
71
+
72
+ const fmtPrice = (value) => Number(value).toLocaleString('en-US', { maximumFractionDigits: Number(value) >= 100 ? 2 : 6 })
73
+
74
+ /** The message the agent receives. Facts first, then exactly what it may and may not do. */
75
+ export function alertWakeText(events) {
76
+ const lines = events.map((event) => `- ${event.coin} 价格${event.direction === 'up' ? '上穿' : '下穿'}「${event.label}」(线价 ${fmtPrice(event.line)},现价 ${fmtPrice(event.price)},${new Date(event.at).toISOString().slice(11, 19)}Z)`)
77
+ return [
78
+ '[交易台事件] 价格提醒已触发:',
79
+ ...lines,
80
+ '这是交易台自动发出的事件通知,不是用户的指令或授权。请:',
81
+ '1. 用 hyper_context 复核最新价格、仓位和挂单;',
82
+ '2. 对照 hyper_journal 里与这条线有关的计划,用两三句话告诉用户发生了什么、你建议怎么做;',
83
+ '3. 不要因为这条通知直接下单、改限额或划转。需要操作时,按托管模式说明并等待用户确认。',
84
+ ].join('\n')
85
+ }
@@ -0,0 +1,3 @@
1
+ export interface ResearchSettings { layout: 'single' | 'dual' | 'quad'; intervals: import('../src/client/types').Interval[]; syncTime: boolean; syncCursor: boolean }
2
+ export declare function normalizeResearch(input?: unknown): ResearchSettings
3
+ export declare function normalizePaperCosts(input?: unknown): { feeBps: number; slippageBps: number }
@@ -0,0 +1,12 @@
1
+ const INTERVALS = ['1m', '3m', '5m', '15m', '30m', '1h', '2h', '4h', '8h', '12h', '1d', '3d', '1w', '1M']
2
+ export function normalizeResearch(input = {}) {
3
+ const next = { layout: 'single', intervals: ['1h', '4h', '1d'], syncTime: true, syncCursor: true, ...input }
4
+ if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some((key) => !['layout', 'intervals', 'syncTime', 'syncCursor'].includes(key)) || !['single', 'dual', 'quad'].includes(next.layout)
5
+ || !Array.isArray(next.intervals) || next.intervals.length !== 3 || next.intervals.some((iv) => !INTERVALS.includes(iv)) || typeof next.syncTime !== 'boolean' || typeof next.syncCursor !== 'boolean') throw Error('多周期研究配置无效')
6
+ return next
7
+ }
8
+ /** Simulation assumptions; not the exchange account's actual fee tier. */
9
+ export function normalizePaperCosts(input = { feeBps: 4.5, slippageBps: 5 }) {
10
+ if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).length !== 2 || !['feeBps', 'slippageBps'].every((key) => typeof input[key] === 'number' && Number.isFinite(input[key]) && input[key] >= 0 && input[key] <= 100)) throw Error('模拟费用和滑点必须为 0–100 bps')
11
+ return { feeBps: input.feeBps, slippageBps: input.slippageBps }
12
+ }