dsh-hyperliquid 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/tools.js ADDED
@@ -0,0 +1,472 @@
1
+ /**
2
+ * dsh-hyperliquid — agent tools. Every trading operation the desk exposes to
3
+ * the model routes through the SAME HyperRemote methods the UI would use
4
+ * (one operation, two callers — the UI simply chooses not to render manual
5
+ * controls), so "所有下单操作都是 dsh 会话进行" holds by construction.
6
+ * @module lib/tools.js
7
+ */
8
+
9
+ import { closedCandles, computeIndicators } from './indicators.js'
10
+ import { MA_KINDS, MA_SOURCES, maLabel, maLatest, normalizeMaLine, normalizeMaLines } from './moving-average.js'
11
+ import { DRAWING_KINDS } from './chart-drawings.js'
12
+
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: '图标' }
14
+ import { canonicalMarketCoin } from './market-symbol.js'
15
+
16
+ /** Resolve the calling session id from a tool run context. */
17
+ function sessionIdOf(exec) {
18
+ const agent = exec?.agent
19
+ return agent?.id ?? agent?.session?.id ?? null
20
+ }
21
+
22
+ function fmt(n, d = 2) {
23
+ if (n == null) return '—'
24
+ const v = Number(n)
25
+ return Number.isFinite(v) ? v.toLocaleString('en-US', { maximumFractionDigits: d }) : '—'
26
+ }
27
+
28
+ function shortSide(isBuy) { return isBuy ? 'BUY/LONG' : 'SELL/SHORT' }
29
+
30
+ /**
31
+ * DSH requires tool output to be lossless JSON: no undefined fields, no NaN / Infinity.
32
+ * Every hyper_* result is normalized here so a stray field can never fail a tool call.
33
+ */
34
+ export function losslessJson(value) {
35
+ return JSON.parse(JSON.stringify(value, (_key, item) => typeof item === 'number' && !Number.isFinite(item) ? null : item))
36
+ }
37
+
38
+ export function buildToolDefinitions(remote) {
39
+ return buildTools(remote).map((tool) => ({ ...tool, async execute(...args) { return losslessJson(await tool.execute(...args)) } }))
40
+ }
41
+
42
+ function buildTools(remote) {
43
+ return [
44
+ {
45
+ name: 'hyper_context',
46
+ description: '一次读取本会话交易上下文:当前币对/周期、价格、盘口和成交、资金费、K 线、账户权益与仓位/挂单、数据时间和风险限额。做交易判断前优先调用。',
47
+ parameters: { type: 'object', properties: { coin: { type: 'string', description: '可选;默认本会话绑定币对或当前交易台币对' } }, additionalProperties: false },
48
+ output: { schema: { type: 'object', additionalProperties: true }, render: (_a, v) => [{ type: 'text', text: v.text }] },
49
+ timeoutMs: 20_000,
50
+ async execute(args, exec) {
51
+ const sessionId = sessionIdOf(exec)
52
+ const row = sessionId ? remote.store.getSession(sessionId) : null
53
+ const coin = canonicalMarketCoin(args?.coin ?? row?.coin ?? remote.desk?.coin)
54
+ if (!coin) throw new Error('无法确定会话币对,请提供 coin')
55
+ const workspace = row ? remote.store.getSessionWorkspace?.(sessionId) ?? null : null
56
+ const interval = workspace?.interval ?? remote.desk?.interval ?? '15m'
57
+ const [markets, snap, account, candleResult, status] = await Promise.all([
58
+ remote.markets(), remote.snapshot({ coin }), remote.account({ coin }), remote.candles({ coin, interval }), remote.status(),
59
+ ])
60
+ const coreMarket = markets.universe.find((m) => m.name === coin)
61
+ const detail = coreMarket ? null : await remote.marketDetail({ coin })
62
+ const market = coreMarket ?? detail?.market
63
+ if (!market) throw new Error(`未知市场 ${coin}`)
64
+ const bid = snap.book?.levels?.[0]?.[0]
65
+ const ask = snap.book?.levels?.[1]?.[0]
66
+ const candles = candleResult.candles.slice(-24)
67
+ const tradable = Boolean(coreMarket)
68
+ 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
71
+ const mark = market.ctx?.markPx != null ? Number(market.ctx.markPx) : null
72
+ const change24hPct = Number(market.ctx?.prevDayPx) > 0 && mark != null ? (mark / Number(market.ctx.prevDayPx) - 1) * 100 : null
73
+ const context = {
74
+ workspace: workspace ? { interval: workspace.interval, bookGrouping: workspace.bookGrouping,
75
+ chartTab: workspace.chartTab, indicators: workspace.indicators, priceScale: workspace.priceScale } : null,
76
+ sessionId, coin, interval, marketKind: detail?.market?.kind ?? 'perp', tradable, network: markets.network, mode: account.mode,
77
+ mark, mid: midFresh ? mid : null, change24hPct, funding1h: market.ctx?.funding != null ? Number(market.ctx.funding) : null,
78
+ volume24hUsd: market.ctx?.dayNtlVlm != null ? Number(market.ctx.dayNtlVlm) : null,
79
+ bid: bid ? { px: Number(bid.px), sz: Number(bid.sz) } : null,
80
+ ask: ask ? { px: Number(ask.px), sz: Number(ask.sz) } : null,
81
+ accountMode: account.accountMode ?? null,
82
+ balanceSource: account.balanceSource ?? null,
83
+ accountDex: account.accountDex ?? '',
84
+ positionsCoverage: account.positionsCoverage ?? null,
85
+ accountValue: account.mode === 'paper' ? account.equity : account.balanceSource === 'spot' ? account.usdcBalance : Number(account.marginSummary?.accountValue),
86
+ available: account.mode === 'paper' ? account.availableMargin : account.balanceSource === 'spot'
87
+ ? account.tradeCapacity?.coin === coin ? Math.min(...account.tradeCapacity.availableToTrade.map(Number)) : null
88
+ : account.withdrawable,
89
+ position, positions: account.positions, openOrders: (account.openOrders ?? []).slice(0, 20), candles,
90
+ recentTrades: (Array.isArray(snap.trades) ? snap.trades : []).slice(0, 10),
91
+ 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(),
94
+ }
95
+ const bookAge = context.observedAt?.book ? Math.round((context.assembledAt - context.observedAt.book) / 1000) : null
96
+ const midsAge = context.observedAt?.mids ? Math.round((context.assembledAt - context.observedAt.mids) / 1000) : null
97
+ const marketAge = context.observedAt?.market ? Math.round((context.assembledAt - context.observedAt.market) / 1000) : null
98
+ const freshness = tradable
99
+ ? bookAge == null || midsAge == null || bookAge > 10 || midsAge > 10 ? '数据过期/缺失,停止交易并重新读取' : '数据新鲜'
100
+ : bookAge == null || marketAge == null || bookAge > 10 || marketAge > 60 ? '只读行情过期/缺失' : '只读行情可用'
101
+ const text = [
102
+ `${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}` : '',
104
+ `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
+ `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
+ !tradable ? account.mode === 'paper'
107
+ ? '当前账户数据不代表该只读市场的余额、仓位或可交易额度。'
108
+ : `当前市场仅可分析;${context.positionsCoverage === 'all-dexs' ? '仓位来自全部永续 DEX 快照' : '账户仓位仅覆盖所选 DEX'}${position ? `;当前仓位=${fmt(position.size, 6)} ${coin}` : ''},不能据此在该市场下单。`
109
+ : 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' ? ' · 模拟盘固定滑点,未模拟盘口深度、手续费、资金费或强平' : ''}`,
111
+ `近 ${candles.length} 根 K 线价格(末根可能未收盘): ${candles.map((c) => fmt(c.c)).join(', ')}`,
112
+ ].join('\n')
113
+ return { text, context }
114
+ },
115
+ },
116
+ {
117
+ name: 'hyper_market',
118
+ 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 查最近成交。非核心永续目前只读。',
121
+ parameters: {
122
+ type: 'object',
123
+ properties: {
124
+ action: { type: 'string', enum: ['overview', 'catalog', 'book', 'candles', 'indicators', 'trades'], description: '行情查询类型' },
125
+ 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
+ coin: { type: 'string', description: '官方合约名,以 action=catalog 返回的 name 为准,如 BTC 或 xyz:XYZ100;overview 时可省略' },
127
+ kind: { type: 'string', enum: ['perp', 'hip3', 'spot', 'outcome'], description: 'catalog 市场类型过滤' },
128
+ category: { type: 'string', description: 'catalog 官方分类过滤,如 crypto、stocks、commodities、indices、fx、rates、preipo;tradfi 聚合这些传统金融标签' },
129
+ query: { type: 'string', description: 'catalog 名称搜索' },
130
+ interval: { type: 'string', description: 'K 线周期,默认 1h' },
131
+ limit: { type: 'number', description: 'candles/trades 返回条数上限,默认 48' },
132
+ },
133
+ required: ['action'],
134
+ additionalProperties: false,
135
+ },
136
+ output: { schema: { type: 'object', additionalProperties: true }, render: (_a, v) => [{ type: 'text', text: v.text }] },
137
+ timeoutMs: 15_000,
138
+ async execute(args, exec) {
139
+ const action = args?.action ?? 'overview'
140
+ if (action === 'overview') {
141
+ const { universe } = await remote.markets()
142
+ const scored = universe
143
+ .filter((u) => u.ctx)
144
+ .sort((a, b) => Number(b.ctx.dayNtlVlm ?? 0) - Number(a.ctx.dayNtlVlm ?? 0))
145
+ .slice(0, 20)
146
+ .map((u) => ({
147
+ coin: u.name,
148
+ mark: Number(u.ctx.markPx),
149
+ change24hPct: Number(u.ctx.prevDayPx) ? ((Number(u.ctx.markPx) - Number(u.ctx.prevDayPx)) / Number(u.ctx.prevDayPx)) * 100 : 0,
150
+ vol24hUsd: Number(u.ctx.dayNtlVlm),
151
+ fundingPct1h: Number(u.ctx.funding) * 100,
152
+ openInterestUsd: Number(u.ctx.openInterest) * Number(u.ctx.markPx),
153
+ maxLeverage: u.maxLeverage,
154
+ }))
155
+ 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
+ }
157
+ if (action === 'catalog') {
158
+ const result = await remote.marketCatalog()
159
+ const query = String(args?.query ?? '').trim().toLowerCase()
160
+ const category = String(args?.category ?? '').trim().toLowerCase()
161
+ const tradfiTags = new Set(['stocks', 'stock', 'equities', 'equity', 'commodities', 'indices', 'fx', 'rates', 'preipo'])
162
+ const limit = Math.max(1, Math.min(Number(args?.limit) || 30, 100))
163
+ const list = result.markets.filter((row) => (!args?.kind || row.kind === args.kind)
164
+ && (!category || (category === 'tradfi' ? tradfiTags.has(String(row.category ?? '').toLowerCase()) : String(row.category ?? '').toLowerCase() === category))
165
+ && (!query || `${row.name} ${row.label} ${row.venue} ${row.category ?? ''}`.toLowerCase().includes(query)))
166
+ .sort((a, b) => Number(b.ctx?.dayNtlVlm ?? 0) - Number(a.ctx?.dayNtlVlm ?? 0)).slice(0, limit)
167
+ return { text: list.map((row) => `${row.label} [${row.kind}${row.category ? ',' + row.category : ''}${row.tradable ? '' : ',只读'}] mark=${row.ctx?.markPx ?? '?'} 24h量=${row.ctx?.dayNtlVlm ?? '?'}`).join('\n') || '没有匹配市场', markets: list, observedAt: result.ctxObservedAt ?? result.observedAt }
168
+ }
169
+ const coin = canonicalMarketCoin(args?.coin)
170
+ if (!coin) throw new Error(`action=${action} 需要 coin`)
171
+ if (action === 'book') {
172
+ const snap = await remote.snapshot({ coin })
173
+ const levels = (side) => (snap.book?.levels?.[side] ?? []).slice(0, 10).map((l) => ({ px: Number(l.px), sz: Number(l.sz) }))
174
+ 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
+ }
176
+ if (action === 'candles') {
177
+ const { candles } = await remote.candles({ coin, interval: args?.interval ?? '1h' })
178
+ const limit = Math.max(1, Math.min(Number(args?.limit) || 48, 200))
179
+ const tail = candles.slice(-limit)
180
+ 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
+ }
182
+ if (action === 'indicators') {
183
+ const interval = args?.interval ?? '1h'
184
+ const { candles } = await remote.candles({ coin, interval })
185
+ const indicators = computeIndicators(candles, { interval })
186
+ if (!indicators.ready) return { text: `${coin} ${interval} 指标数据不足:${indicators.reason};已排除 ${indicators.excludedOpen} 根未收盘 K 线`, indicators }
187
+ const ageMin = Math.max(0, Math.round((Date.now() - indicators.observedAt) / 60_000))
188
+ const text = [
189
+ `${coin} ${interval} · ${indicators.candlesUsed} 根已收盘 K 线 · 最新收盘 ${new Date(indicators.observedAt).toISOString()}(${ageMin} 分钟前) · 排除 ${indicators.excludedOpen} 根形成中 K 线`,
190
+ `收盘=${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)}`,
193
+ `邻近支撑=${fmt(indicators.support.price)}(${new Date(indicators.support.time).toISOString()}) 阻力=${fmt(indicators.resistance.price)}(${new Date(indicators.resistance.time).toISOString()})`,
194
+ ]
195
+ let session = null
196
+ try { session = sessionIdOf(exec) } catch { /* not a trading session */ }
197
+ const requested = Array.isArray(args?.ma) ? normalizeMaLines(args.ma) : (session && remote.store.getSession?.(session) ? remote.store.getSessionWorkspace(session).maLines ?? [] : [])
198
+ const ma = requested.length ? maLatest(closedCandles(candles, { interval }).rows, requested) : []
199
+ if (ma.length) text.push('均线(最新已收盘):' + ma.map((row) => `${row.label}=${row.value == null ? '数据不足' : fmt(row.value)}${row.line.smoothing ? `→平滑${fmt(row.smoothed)}` : ''}`).join(' '))
200
+ text.push('指标是历史 K 线的派生值;需要结合最新盘口、账户风险和数据时效验证,不能单独当作下单信号。')
201
+ return { text: text.join('\n'), indicators, ma, interval, coin }
202
+ }
203
+ if (action === 'trades') {
204
+ const snap = await remote.snapshot({ coin })
205
+ const list = (Array.isArray(snap.trades) ? snap.trades : []).slice(0, Math.max(1, Math.min(Number(args?.limit) || 20, 50)))
206
+ return { text: list.map((t) => `${new Date(t.time).toISOString().slice(11, 19)} ${t.side} ${fmt(t.sz, 4)} @ ${fmt(t.px)}`).join('\n'), trades: list }
207
+ }
208
+ throw new Error(`未知 action: ${action}`)
209
+ },
210
+ },
211
+ {
212
+ 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 验证价格和时间;标注仅用于解释,不是自动订单或交易信号。',
214
+ parameters: {
215
+ type: 'object', properties: {
216
+ action: { type: 'string', enum: ['add', 'list', 'update', 'remove'] },
217
+ coin: { type: 'string', description: '默认会话绑定币对' },
218
+ interval: { type: 'string', description: '默认当前交易台周期' },
219
+ kind: { type: 'string', enum: [...DRAWING_KINDS, 'ma'], description: 'add 必填:画线类型或 ma 均线' },
220
+ points: { type: 'array', description: 'hray/vline/ray/extended/channel/fib/rect/measure 的锚点,按点击顺序', items: { type: 'object', properties: { time: { type: 'number', description: 'K 线时间 epoch ms' }, price: { type: 'number' } }, required: ['time', 'price'] } },
221
+ maType: { type: 'string', enum: MA_KINDS, description: 'kind=ma:均线类型;smma 即平滑均线(RMA)' },
222
+ period: { type: 'number', description: 'kind=ma:周期,1-500 的整数' },
223
+ source: { type: 'string', enum: MA_SOURCES, description: 'kind=ma:价格源,默认 close' },
224
+ smoothingType: { type: 'string', enum: MA_KINDS, description: 'kind=ma 可选:对均线再做一次平滑的类型' },
225
+ smoothingPeriod: { type: 'number', description: 'kind=ma 可选:平滑周期' },
226
+ color: { type: 'string', description: '可选 #RRGGBB 颜色(画线或均线)' },
227
+ lineWidth: { type: 'number', description: '画线线宽 1-4' },
228
+ lineStyle: { type: 'string', enum: ['solid', 'dashed', 'dotted'], description: '画线线型' },
229
+ locked: { type: 'boolean', description: 'update:锁定后用户不能误拖动' },
230
+ hidden: { type: 'boolean', description: 'update:在图上隐藏(保留在画线列表中)' },
231
+ shared: { type: 'boolean', description: '同步到该币种的所有交易会话' },
232
+ alert: { type: 'boolean', description: '价格穿越提醒(level/hray/trend/ray/extended),触发一次后自动关闭' },
233
+ role: { type: 'string', enum: ['support', 'resistance', 'neutral'], description: '视觉语义' },
234
+ label: { type: 'string', description: '图上展示的短标签,1-60 字' },
235
+ price: { type: 'number', description: '水平线价格' },
236
+ fromTime: { type: 'number', description: '趋势线起点 K 线时间,epoch ms' },
237
+ fromPrice: { type: 'number', description: '趋势线起点价格' },
238
+ toTime: { type: 'number', description: '趋势线终点 K 线时间,epoch ms' },
239
+ toPrice: { type: 'number', description: '趋势线终点价格' },
240
+ id: { type: 'string', description: 'remove 时使用 list 返回的标注或均线 ID' },
241
+ }, required: ['action'], additionalProperties: false,
242
+ },
243
+ output: { schema: { type: 'object', additionalProperties: true }, render: (_a, v) => [{ type: 'text', text: v.text }] },
244
+ async execute(args, exec) {
245
+ const sessionId = sessionIdOf(exec)
246
+ remote.requireTradingSession(sessionId)
247
+ const coin = canonicalMarketCoin(args?.coin ?? remote.store.getSession(sessionId)?.coin ?? remote.desk.coin)
248
+ const interval = args?.interval ?? remote.store.getSessionWorkspace?.(sessionId)?.interval ?? remote.desk.interval
249
+ const maLines = () => remote.store.getSessionWorkspace?.(sessionId)?.maLines ?? []
250
+ const maValues = async (lines) => {
251
+ if (!lines.length) return []
252
+ const { candles } = await remote.candles({ coin, interval })
253
+ return maLatest(closedCandles(candles, { interval }).rows, lines)
254
+ }
255
+ const maText = (row) => `${row.id} ${row.label} = ${row.value == null ? '数据不足' : fmt(row.value)}${row.line.smoothing ? `,平滑 = ${row.smoothed == null ? '数据不足' : fmt(row.smoothed)}` : ''}`
256
+ if (args?.action === 'list') {
257
+ const result = await remote.chartMarks({ sessionId, coin, allIntervals: true })
258
+ const ma = await maValues(maLines())
259
+ const lines = [...result.marks.map((mark) => `${mark.id} ${mark.interval} ${mark.kind} ${mark.role} ${mark.label}`), ...ma.map((row) => `${maText(row)}(${coin} ${interval} 最新已收盘)`)]
260
+ return { text: lines.length ? lines.join('\n') : '当前标的没有标注或均线', ...result, maLines: ma }
261
+ }
262
+ if (args?.action === 'add' && args?.kind === 'ma') {
263
+ const line = normalizeMaLine({ kind: args.maType, period: args.period, source: args.source, color: args.color,
264
+ smoothing: args.smoothingType != null || args.smoothingPeriod != null ? { kind: args.smoothingType, period: args.smoothingPeriod } : null })
265
+ const next = remote.setSessionMaLines(sessionId, [...maLines().filter((row) => row.id !== line.id), line])
266
+ const [row] = await maValues([next.find((item) => item.id === line.id)])
267
+ return { text: `已在 ${coin} 图表叠加均线 ${maText(row)}(${interval} 最新已收盘),切换周期会自动重算。`, line: row.line, value: row.value, smoothed: row.smoothed }
268
+ }
269
+ if (args?.action === 'remove' && maLines().some((row) => row.id === args?.id)) {
270
+ const removed = maLines().find((row) => row.id === args.id)
271
+ remote.setSessionMaLines(sessionId, maLines().filter((row) => row.id !== args.id))
272
+ return { text: `已移除均线:${maLabel(removed)}(${removed.id})`, line: removed }
273
+ }
274
+ 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]]))
276
+ const mark = remote.editChartMark(args?.id, patch, sessionId)
277
+ return { text: `已更新${DRAWING_NAMES[mark.kind] ?? mark.kind}:${mark.label}(${mark.id})`, mark }
278
+ }
279
+ if (args?.action === 'add') {
280
+ const mark = remote.addChartMark({ ...args, coin, interval }, sessionId)
281
+ return { text: `已在 ${coin} ${interval} 图表添加${DRAWING_NAMES[mark.kind] ?? mark.kind}:${mark.label}(${mark.id})`, mark }
282
+ }
283
+ if (args?.action === 'remove') {
284
+ const mark = remote.removeChartMark(args?.id, sessionId)
285
+ return { text: `已删除图表标注:${mark.label}(${mark.id})`, mark }
286
+ }
287
+ throw new Error(`未知 action: ${args?.action}`)
288
+ },
289
+ },
290
+ {
291
+ name: 'hyper_account',
292
+ description: '查询交易账户。action=state 返回权益/持仓;orders 返回挂单;fills 返回成交;orderStatus 用 oid 或 cloid 核对某笔 live 订单,尤其在网络超时后。',
293
+ parameters: {
294
+ type: 'object',
295
+ properties: {
296
+ action: { type: 'string', enum: ['state', 'orders', 'fills', 'orderStatus'], description: '账户查询类型,默认 state' },
297
+ oid: { type: 'string', description: 'orderStatus 必填;交易所 oid 或 0x 开头 128 位 cloid' },
298
+ },
299
+ additionalProperties: false,
300
+ },
301
+ output: { schema: { type: 'object', additionalProperties: true }, render: (_a, v) => [{ type: 'text', text: v.text }] },
302
+ timeoutMs: 15_000,
303
+ async execute(args) {
304
+ const action = args?.action ?? 'state'
305
+ if (action === 'state') {
306
+ const a = await remote.account({ coin: remote.desk?.coin })
307
+ const lines = [`mode=${a.mode}${a.address ? ' ' + a.address : ''}${a.accountDex ? ` DEX=${a.accountDex}` : ''}${a.positionsCoverage ? ` positions=${a.positionsCoverage}` : ''}`]
308
+ if (a.mode === 'paper') lines.push(`现金=${fmt(a.usd)} 权益=${fmt(a.equity)} 已用保证金=${fmt(a.marginUsed)} 可用保证金=${fmt(a.availableMargin)} uPnL=${fmt(a.uPnl)};${a.simulation}`)
309
+ else if (a.balanceSource === 'spot') lines.push(`账户模式=${a.accountMode} USDC余额=${fmt(a.usdcBalance)} 未占用=${fmt(a.unheldUsdc)}${a.tradeCapacity ? ` ${a.tradeCapacity.coin}可交易名义额=${fmt(Math.min(...a.tradeCapacity.availableToTrade.map(Number)))}` : ''}`)
310
+ else if (a.marginSummary) lines.push(`账户模式=${a.accountMode} 账户价值=${fmt(a.marginSummary.accountValue)} 可用=${fmt(a.withdrawable)}`)
311
+ for (const p of a.positions) {
312
+ const leverage = typeof p.leverage === 'number' ? p.leverage : Number(p.leverage?.value ?? p.leverage?.leverage)
313
+ lines.push(`${p.coin} ${p.size > 0 ? 'LONG' : 'SHORT'} sz=${fmt(Math.abs(p.size), 4)} entry=${fmt(p.entryPx)} mark=${fmt(p.markPx)} uPnL=${fmt(p.uPnl)}` +
314
+ `${Number.isFinite(leverage) && leverage > 0 ? ` lev=${leverage}x` : ''}` +
315
+ `${p.returnOnEquity == null ? '' : ` ROE=${fmt(Number(p.returnOnEquity) * 100)}%`}` +
316
+ `${p.liquidationPx == null ? '' : ` liq=${fmt(p.liquidationPx)}`}` +
317
+ `${p.marginUsed == null ? '' : ` margin=${fmt(p.marginUsed)}`}`)
318
+ }
319
+ if (a.positions.length === 0) lines.push('(无持仓)')
320
+ return { text: lines.join('\n'), account: a }
321
+ }
322
+ if (action === 'orders') {
323
+ const o = await remote.orders()
324
+ const rows = o.orders ?? []
325
+ return { text: rows.length ? rows.map((r) => `${r.coin} ${r.side ?? ''} ${fmt(r.origSz ?? r.sz, 4)} @ ${fmt(r.limitPx ?? r.px)} oid=${r.oid}`).join('\n') : '(无挂单)', orders: rows }
326
+ }
327
+ if (action === 'fills') {
328
+ const f = await remote.fills()
329
+ const rows = f.fills ?? []
330
+ 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
+ }
332
+ if (action === 'orderStatus') {
333
+ const result = await remote.orderStatus({ oid: args?.oid })
334
+ return { text: JSON.stringify(result), ...result }
335
+ }
336
+ throw new Error(`未知 action: ${action}`)
337
+ },
338
+ },
339
+ {
340
+ name: 'hyper_decide',
341
+ description: '对一个 live 交易意图做服务端预检:读取实时价格、账户权益和仓位,执行币对/单笔/每日/仓位/杠杆硬限制,并生成 30 秒、仅本会话可用的一次性 decisionId。可选 takeProfitPx/stopLossPx 会随主单作为 normalTpsl 分组保护单提交;父单未完全成交时保护单尚未挂出,部分成交后撤销父单会撤销子单,必须核对真实仓位与保护挂单。paper 暂不支持触发单。先独立分析行情与风险,再提交方向、数量和依据;预检通过不等于交易所已成交。',
342
+ parameters: {
343
+ type: 'object',
344
+ properties: {
345
+ coin: { type: 'string' }, isBuy: { type: 'boolean' }, sz: { type: 'number' },
346
+ type: { type: 'string', enum: ['market', 'limit'] }, px: { type: 'number' },
347
+ tif: { type: 'string', enum: ['Gtc', 'Alo'] }, reduceOnly: { type: 'boolean' },
348
+ takeProfitPx: { type: 'number', description: '可选止盈触发价;与 hyper_order 完全一致' },
349
+ stopLossPx: { type: 'number', description: '可选止损触发价;与 hyper_order 完全一致' },
350
+ thesis: { type: 'string', description: '交易依据、反证条件与风险,10-1000 字' },
351
+ },
352
+ required: ['coin', 'isBuy', 'sz', 'thesis'], additionalProperties: false,
353
+ },
354
+ output: { schema: { type: 'object', additionalProperties: true }, render: (_a, v) => [{ type: 'text', text: v.text }] },
355
+ timeoutMs: 20_000,
356
+ async execute(args, exec) {
357
+ const result = await remote.prepareOrder(args, sessionIdOf(exec))
358
+ return { text: `交易预检通过:${result.side} ${result.coin},价格=${fmt(result.mid)},风险估值=${fmt(result.notionalUsd)} USD,当前仓位=${fmt(result.positionSize, 6)},执行后仓位=${fmt(result.nextSize, 6)}。decisionId=${result.decisionId}(30 秒内一次性使用)。`, ...result }
359
+ },
360
+ },
361
+ {
362
+ name: 'hyper_order',
363
+ 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 钱包策略约束。',
366
+ parameters: {
367
+ type: 'object',
368
+ properties: {
369
+ action: { type: 'string', enum: ['place', 'cancel', 'leverage'], description: '交易动作' },
370
+ coin: { type: 'string', description: '合约名,如 BTC' },
371
+ isBuy: { type: 'boolean', description: 'true=买入/做多,false=卖出/做空(place 必填)' },
372
+ sz: { type: 'number', description: '数量(币本位,如 0.5 BTC)(place 必填)' },
373
+ type: { type: 'string', enum: ['market', 'limit'], description: '订单类型,默认 market' },
374
+ px: { type: 'number', description: '限价单价格(type=limit 必填)' },
375
+ tif: { type: 'string', enum: ['Gtc', 'Alo'], description: '限价单时效,默认 Gtc' },
376
+ reduceOnly: { type: 'boolean', description: '只减仓,默认 false' },
377
+ takeProfitPx: { type: 'number', description: '可选止盈触发价;paper 不支持' },
378
+ stopLossPx: { type: 'number', description: '可选止损触发价;paper 不支持' },
379
+ decisionId: { type: 'string', description: 'hyper_decide 返回的本会话一次性决策 ID(live 必填)' },
380
+ reason: { type: 'string', description: 'paper 下单原因,10-1000 字;live 使用 hyper_decide.thesis' },
381
+ oid: { type: 'number', description: '撤单的订单 id(cancel 必填)' },
382
+ leverage: { type: 'number', description: '杠杆倍数 1-50(leverage 必填)' },
383
+ marginMode: { type: 'string', enum: ['cross', 'isolated'], description: '保证金模式,默认 cross' },
384
+ },
385
+ required: ['action'],
386
+ additionalProperties: false,
387
+ },
388
+ output: { schema: { type: 'object', additionalProperties: true }, render: (_a, v) => [{ type: 'text', text: v.text }] },
389
+ timeoutMs: 30_000,
390
+ async execute(args, exec) {
391
+ const action = args?.action
392
+ const sessionId = sessionIdOf(exec)
393
+ if (action === 'place') {
394
+ const coin = canonicalMarketCoin(args?.coin)
395
+ if (!coin) throw new Error('place 需要 coin')
396
+ if (typeof args?.isBuy !== 'boolean') throw new Error('place 需要明确的 isBuy')
397
+ const isBuy = args.isBuy
398
+ const sz = Number(args?.sz)
399
+ const type = args?.type === 'limit' ? 'limit' : 'market'
400
+ const reason = String(args?.reason ?? '').trim()
401
+ if (remote.cfg.tradingMode === 'paper' && (reason.length < 10 || reason.length > 1000)) throw new Error('paper 下单需要 10-1000 字的原因')
402
+ const summary = `${shortSide(isBuy)} ${fmt(sz, 6)} ${coin} @ ${type === 'limit' ? fmt(args?.px) + ' (limit)' : '市价'}${args?.reduceOnly ? ' [只减仓]' : ''} · 会话=${sessionId ?? '未知'}`
403
+ const res = await remote.executeToolOrder({
404
+ coin, isBuy, sz, type, px: args?.px, tif: args?.tif, reduceOnly: args?.reduceOnly,
405
+ takeProfitPx: args?.takeProfitPx, stopLossPx: args?.stopLossPx, decisionId: args?.decisionId,
406
+ reason,
407
+ }, exec, sessionId)
408
+ if (res.mode === 'paper') {
409
+ const f = res.fill
410
+ return {
411
+ text: `✅ paper 成交 ${shortSide(f.isBuy)} ${fmt(f.sz, 6)} ${f.coin} @ ${fmt(f.px)}(名义=${fmt(f.notional)},实现盈亏=${fmt(f.realized)})。中间台已同步。`,
412
+ fill: f,
413
+ }
414
+ }
415
+ const statuses = res.response?.response?.data?.statuses ?? []
416
+ const protectionNote = res.protectionState === 'parent-not-fully-filled'
417
+ ? '\n⚠ 父单未确认完全成交,止盈止损尚不能视为生效;立即核对实际仓位和保护挂单。'
418
+ : res.protectionState === 'verify-active'
419
+ ? '\n止盈止损已随完整成交父单提交;仍需核对实际保护挂单。' : ''
420
+ return { text: `live 订单已提交:${summary}\n交易所回执: ${JSON.stringify(statuses).slice(0, 400)}${protectionNote}`, response: res.response, cloid: res.cloid, protectionState: res.protectionState }
421
+ }
422
+ if (action === 'cancel') {
423
+ const res = await remote.executeToolCancel({ coin: args?.coin, oid: args?.oid }, exec)
424
+ return { text: `撤单已提交(${args?.coin} oid=${args?.oid}): ${JSON.stringify(res.response).slice(0, 300)}` }
425
+ }
426
+ if (action === 'leverage') {
427
+ const res = await remote.executeToolLeverage({ coin: args?.coin, leverage: args?.leverage, marginMode: args?.marginMode }, exec)
428
+ return { text: `杠杆设置已提交:${args?.coin} = ${args?.leverage}x (${args?.marginMode ?? 'cross'})${res.mode === 'paper' ? '(paper 本地生效)' : ''}`, response: res.response ?? null }
429
+ }
430
+ throw new Error(`未知 action: ${action}`)
431
+ },
432
+ },
433
+ {
434
+ name: 'hyper_session',
435
+ description:
436
+ '交易会话上下文。action=status 返回本会话绑定的币对与当前交易台显示状态(用户正在看什么);action=bind 把用户在中间台选中的币对与本会话绑定。' +
437
+ '对话中引用"当前界面/当前币"时先调它同步视角。',
438
+ parameters: {
439
+ type: 'object',
440
+ properties: {
441
+ action: { type: 'string', enum: ['status', 'bind'], description: '会话动作,默认 status' },
442
+ coin: { type: 'string', description: 'action=bind 时绑定 action=catalog 返回的官方合约名,如 xyz:XYZ100' },
443
+ },
444
+ additionalProperties: false,
445
+ },
446
+ output: { schema: { type: 'object', additionalProperties: true }, render: (_a, v) => [{ type: 'text', text: v.text }] },
447
+ timeoutMs: 10_000,
448
+ async execute(args, exec) {
449
+ const sessionId = sessionIdOf(exec)
450
+ const action = args?.action ?? 'status'
451
+ if (action === 'bind') {
452
+ const coin = canonicalMarketCoin(args?.coin)
453
+ if (!coin) throw new Error('bind 需要 coin')
454
+ // The desk follows the agent's bind: one desk, one focus.
455
+ await remote.deskState({ coin }).catch(() => {})
456
+ const row = sessionId
457
+ ? remote.store.registerSession(sessionId, { coin })
458
+ : { coin }
459
+ return { text: `本会话交易视角已绑定到 ${coin}${sessionId ? '(交易台已跟随)' : ''}`, session: row }
460
+ }
461
+ const row = sessionId ? remote.store.getSession(sessionId) : null
462
+ const st = await remote.status()
463
+ const desk = st.desk ?? remote.desk ?? {}
464
+ const lines = [`mode=${st.tradingMode} network=${st.network}${st.address ? ' ' + st.address : ''}`]
465
+ if (desk.coin) lines.push(`交易台当前焦点: ${desk.coin} · K线 ${desk.interval ?? '15m'}`)
466
+ if (row) lines.push(`本会话绑定币对: ${row.coin}`)
467
+ else lines.push('本会话尚未绑定币对(可用 action=bind 绑定,或让用户在中间台选择)')
468
+ return { text: lines.join('\n'), session: row, status: st }
469
+ },
470
+ },
471
+ ]
472
+ }
@@ -0,0 +1,86 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ const TTL_MS = 30_000
6
+
7
+ /** Server-only, one-use trade plans and conservative durable daily budgets. */
8
+ export class TradingGuard {
9
+ constructor(dataDir, limits, { budgetName = 'live-budget.json' } = {}) {
10
+ this.limits = limits
11
+ this.plans = new Map()
12
+ this.budgetFile = join(dataDir, budgetName)
13
+ this.budget = existsSync(this.budgetFile)
14
+ ? JSON.parse(readFileSync(this.budgetFile, 'utf8'))
15
+ : { day: '', usedUsd: 0 }
16
+ if (typeof this.budget.day !== 'string' || !Number.isFinite(this.budget.usedUsd) || this.budget.usedUsd < 0) {
17
+ throw new Error('live 交易额度账本损坏,拒绝启动')
18
+ }
19
+ }
20
+
21
+ validate({ coin, isBuy, sz, price, positionSize, otherExposureUsd = 0, accountValue, leverage, reduceOnly }) {
22
+ const l = this.limits
23
+ if (typeof isBuy !== 'boolean' || !Number.isFinite(sz) || sz <= 0 || !Number.isFinite(price) || price <= 0) throw new Error('订单方向、数量或价格无效')
24
+ if (!Number.isFinite(accountValue) || !Number.isFinite(otherExposureUsd) || otherExposureUsd < 0) throw new Error('无法验证账户权益/敞口,拒绝下单')
25
+ if (!Number.isFinite(positionSize) || !Number.isFinite(leverage) || leverage < 1) throw new Error('持仓或杠杆超出交易权限')
26
+ const signed = (isBuy ? 1 : -1) * sz
27
+ const nextSize = positionSize + signed
28
+ if (reduceOnly && (positionSize === 0 || Math.sign(signed) === Math.sign(positionSize) || sz > Math.abs(positionSize) + 1e-10)) throw new Error('只减仓订单会增加或翻转仓位')
29
+ const riskReducing = positionSize !== 0 && Math.sign(signed) !== Math.sign(positionSize) &&
30
+ (nextSize === 0 || Math.sign(nextSize) === Math.sign(positionSize)) && Math.abs(nextSize) < Math.abs(positionSize)
31
+ if (!riskReducing && !l.allowedCoins.includes(coin)) throw new Error(`交易限额:${coin} 不在允许交易币对内(allowedCoins)`)
32
+ if (!riskReducing && leverage > l.maxLeverage) throw new Error(`交易限额:杠杆 ${leverage}x 超过上限 ${l.maxLeverage}x(maxLeverage)`)
33
+ const notionalUsd = sz * price
34
+ if (!riskReducing && accountValue <= 0) throw new Error('账户权益不足,拒绝开仓')
35
+ if (!riskReducing && notionalUsd > l.maxOrderUsd) throw new Error(`交易限额:单笔名义价值 ${notionalUsd.toFixed(2)} 超过上限 ${l.maxOrderUsd} USD(maxOrderUsd)`)
36
+ if (!riskReducing && Math.abs(nextSize) * price > l.maxPositionUsd) throw new Error(`交易限额:执行后 ${coin} 仓位超过 ${l.maxPositionUsd} USD(maxPositionUsd)`)
37
+ if (!riskReducing && Math.abs(nextSize) * price > accountValue * l.maxLeverage) throw new Error('执行后仓位超过权益允许的杠杆敞口')
38
+ if (!riskReducing && otherExposureUsd + Math.abs(nextSize) * price > accountValue * l.maxLeverage) throw new Error('执行后全账户敞口超过权益允许的杠杆倍数')
39
+ this.rollDay()
40
+ if (!riskReducing && this.budget.usedUsd + notionalUsd > l.maxDailyUsd) throw new Error(`交易限额:今日下单额将超过 ${l.maxDailyUsd} USD(maxDailyUsd,UTC 日重置)`)
41
+ return { notionalUsd, nextSize, riskReducing }
42
+ }
43
+
44
+ rollDay() {
45
+ const day = new Date().toISOString().slice(0, 10)
46
+ if (this.budget.day !== day) this.budget = { day, usedUsd: 0 }
47
+ }
48
+
49
+ create(order, sessionId, preview) {
50
+ const id = randomUUID()
51
+ this.plans.set(id, { order, sessionId, preview, expiresAt: Date.now() + TTL_MS })
52
+ for (const [key, plan] of this.plans) if (plan.expiresAt <= Date.now()) this.plans.delete(key)
53
+ return { decisionId: id, expiresAt: Date.now() + TTL_MS, ...preview }
54
+ }
55
+
56
+ consume(id, order, sessionId) {
57
+ const plan = this.plans.get(id)
58
+ this.plans.delete(id)
59
+ if (!plan || plan.expiresAt <= Date.now()) throw new Error('交易决策已失效,请重新预检')
60
+ if (plan.sessionId !== sessionId || JSON.stringify(plan.order) !== JSON.stringify(order)) throw new Error('订单与预检决策不一致')
61
+ return plan.preview
62
+ }
63
+
64
+ reserve(notionalUsd, { riskReducing = false } = {}) {
65
+ this.rollDay()
66
+ if (riskReducing) return { day: this.budget.day, notionalUsd: 0 }
67
+ if (this.budget.usedUsd + notionalUsd > this.limits.maxDailyUsd) throw new Error('交易限额:今日下单额度已耗尽(maxDailyUsd,UTC 日重置)')
68
+ this.budget.usedUsd += notionalUsd
69
+ mkdirSync(this.limits.dataDir, { recursive: true })
70
+ const tmp = this.budgetFile + '.tmp'
71
+ writeFileSync(tmp, JSON.stringify(this.budget), { mode: 0o600 })
72
+ renameSync(tmp, this.budgetFile)
73
+ return { day: this.budget.day, notionalUsd }
74
+ }
75
+
76
+ /** Return quota only when the exchange definitively rejected the action. */
77
+ release(ticket) {
78
+ this.rollDay()
79
+ if (!ticket || ticket.day !== this.budget.day) return
80
+ this.budget.usedUsd = Math.max(0, this.budget.usedUsd - ticket.notionalUsd)
81
+ mkdirSync(this.limits.dataDir, { recursive: true })
82
+ const tmp = this.budgetFile + '.tmp'
83
+ writeFileSync(tmp, JSON.stringify(this.budget), { mode: 0o600 })
84
+ renameSync(tmp, this.budgetFile)
85
+ }
86
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Hyperliquid Desk",
4
+ "description": "A peer-level Hyperliquid trading platform for dsh: sessions, live market data, and an embedded native conversation for decisions and execution."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Hyperliquid 交易台",
4
+ "description": "与 bots 平级的 Hyperliquid 交易平台:左侧交易会话、中间实时行情与账户、右侧嵌入 DSH 原生会话;交易意图由会话决策执行。"
5
+ }
6
+ }