dsh-hyperliquid 0.3.0 → 0.4.1

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/index.js CHANGED
@@ -9,6 +9,10 @@
9
9
  * @module dsh-hyperliquid
10
10
  */
11
11
 
12
+ import { randomUUID } from 'node:crypto'
13
+ import { deskPrompt } from './lib/prompt.js'
14
+ import { createAgentWaker } from './lib/wake.js'
15
+ import { createTurnOriginGuard } from './lib/turn-origin.js'
12
16
  import { HyperRemote } from './lib/remote.js'
13
17
  import { buildToolDefinitions } from './lib/tools.js'
14
18
  import { registerHyperSkill } from './lib/skill.js'
@@ -43,12 +47,13 @@ export const Config = z.object({
43
47
  signer: z.union([z.const('local'), z.const('web3')]).description('签名方式:local 插件内私钥签名;web3 交给 dsh-web3 钱包保险库签名(私钥不出保险库,逐笔过签名策略与审批)').default('local'),
44
48
  web3Account: z.string().description('signer=web3 时使用的 dsh-web3 账户 id 或名称').default('main'),
45
49
  tradingAddress: z.string().description('Hyperliquid 主账户地址;使用 dsh-web3 API wallet 签名时必填,账户查询必须用主账户地址').default(''),
46
- autonomousLive: z.boolean().description('允许 agent 在硬性限额内自主提交 live 订单;默认关闭,且仅支持 dsh-web3 签名').default(false),
50
+ autonomousLive: z.boolean().description('初始托管模式:true=全托管(agent 自主执行实盘、可调整限额与划转保证金),false=半托管;之后以交易台 设置 → 托管模式 为准。仅支持 dsh-web3 签名').default(false),
47
51
  allowedCoins: z.string().description('交易限额(paper/live 均生效):允许新开仓的币对,逗号分隔;减仓不受限').default('BTC,ETH'),
48
52
  maxOrderUsd: z.number().description('交易限额(paper/live 均生效):单笔名义价值上限 USD').default(100),
49
53
  maxDailyUsd: z.number().description('交易限额(paper/live 均生效):每 UTC 日累计开仓名义价值上限 USD,paper 与 live 分账').default(500),
50
54
  maxPositionUsd: z.number().description('交易限额(paper/live 均生效):每币最大仓位名义价值 USD').default(500),
51
55
  maxLeverage: z.number().description('交易限额(paper/live 均生效):最大杠杆倍数').default(2),
56
+ wakeAgentOnAlert: z.boolean().description('价格提醒触发时唤醒该交易会话的 agent(只通知并复核,不会自动交易;同一会话 5 分钟内最多一次)').default(true),
52
57
  }).description('Hyperliquid 交易台配置')
53
58
 
54
59
  /**
@@ -62,10 +67,13 @@ export function apply(ctx, config = {}) {
62
67
  // Live channel lifecycle: stop the upstream WS on teardown.
63
68
  ctx.effect(() => () => remote.stopWs())
64
69
 
70
+ // The persisted turn origin covers a plugin reload in the middle of an alert turn.
71
+ const authorizeTurn = createTurnOriginGuard(ctx, { origin: { get: (sessionId) => remote.store.alertOrigin(sessionId), set: (sessionId, alert) => remote.store.setAlertOrigin(sessionId, alert) } })
72
+
65
73
  // Agent tools (optional service — degrade gracefully).
66
74
  const tools = typeof ctx?.get === 'function' ? ctx.get('tools') : undefined
67
75
  if (tools && typeof tools.register === 'function') {
68
- for (const def of buildToolDefinitions(remote)) {
76
+ for (const def of buildToolDefinitions(remote, authorizeTurn)) {
69
77
  const remove = tools.register(def)
70
78
  disposers.push(remove)
71
79
  ctx.effect(() => () => { try { remove() } catch { /* already gone */ } })
@@ -74,6 +82,29 @@ export function apply(ctx, config = {}) {
74
82
  ctx.logger?.warn?.('[dsh-hyperliquid] tools 服务不可用,仅提供 UI 远端')
75
83
  }
76
84
 
85
+ // Price alerts wake the owning session's agent (notify-only; guard rails live in lib/wake.js).
86
+ if (config?.wakeAgentOnAlert !== false && typeof ctx?.get === 'function') {
87
+ const waker = createAgentWaker({
88
+ deliver: async (sessionId, text, { signal }) => {
89
+ if (signal.aborted) return false
90
+ // Resolved at delivery time: optional services may register after this plugin applies.
91
+ const sessionController = ctx.get('sessionController')
92
+ if (typeof sessionController?.resolveAgent !== 'function') return false
93
+ const resolved = await sessionController.resolveAgent(sessionId)
94
+ if (signal.aborted || !resolved || 'error' in resolved || typeof resolved.agent?.followup !== 'function') return false
95
+ // Same shape as dsh-llm createUserMessage: an identified, frozen user-role message with its producer.
96
+ // Mark the session before delivering: the turn this starts is an alert turn even if we reload mid-way.
97
+ remote.store.setAlertOrigin(sessionId, true)
98
+ resolved.agent.followup(deepFreeze({ role: 'user', id: randomUUID(), content: [{ type: 'text', text }], source: { kind: 'plugin:dsh-hyperliquid', form: 'event' } }))
99
+ if (!signal.aborted) await ctx.get('sessions')?.flush?.(resolved.agent.session)
100
+ return true
101
+ },
102
+ })
103
+ const onAlert = (event) => { waker.notify(event) }
104
+ remote.alertListeners.add(onAlert)
105
+ ctx.effect(() => () => { remote.alertListeners.delete(onAlert); waker.dispose() })
106
+ }
107
+
77
108
  const skillRemove = registerHyperSkill(typeof ctx?.get === 'function' ? ctx.get('skills') : null)
78
109
  if (skillRemove) {
79
110
  disposers.push(skillRemove)
@@ -88,24 +119,8 @@ export function apply(ctx, config = {}) {
88
119
  const remove = systemPrompt.section({
89
120
  name: 'hyperliquid-desk',
90
121
  order: 500,
91
- text: () => {
92
- const mode = remote.cfg.tradingMode
93
- const net = remote.cfg.network
94
- const lines = [
95
- '## Hyperliquid 交易台(dsh-hyperliquid)',
96
- `本 Harness 装有 Hyperliquid 交易台(mode=${mode},network=${net})。用户可在面板看行情并填写交易票据;票据只把意图送入当前会话,所有实际下单只能由你调用工具完成。`,
97
- '可用工具只有这 7 个,没有其它 hyper_* 工具:hyper_context(会话交易上下文)、hyper_market(行情:action=overview/catalog/book 盘口/candles/indicators/trades)、hyper_chart(画线 41 种、kind=ma 均线、action=view 切周期与 RSI/MACD 等副图指标)、hyper_account(账户)、hyper_decide(交易决策预检并返回一次性 decisionId)、hyper_order(执行/撤单/杠杆)、hyper_session(会话视角与绑定币对)。',
98
- '均线用 hyper_chart add kind=ma、RSI/MACD 等用 hyper_chart action=view show=[...],图表会实时计算;不要自己计算均线再用多段趋势线拼折线。工具返回错误时换正确参数重试一次,仍失败就直接告诉用户,不要反复推演。',
99
- '用户说"当前/界面上这个币"时,先 hyper_context 或 hyper_session status;需要技术判断时用 hyper_market indicators 验证趋势、动量、波动和支撑阻力,可用 hyper_chart 将依据画给用户看。交易前核对行情与账户,说明方向、数量、依据及风险。paper 模式用 hyper_order 执行;live 模式先用 hyper_decide 预检,并用返回的一次性 decisionId 执行 hyper_order。是否可自主执行由宿主配置和钱包策略决定。',
100
- ]
101
- if (skillRemove) lines.push('复杂的 Hyperliquid 分析、交易或异常对账,请先加载 hyperliquid-trading skill。')
102
- // Live desk focus: what coin/interval the desk shows right now.
103
- try {
104
- const desk = remote.desk ?? {}
105
- if (desk.coin) lines.push(`当前交易台焦点:${desk.coin} · K线 ${desk.interval ?? '15m'}。用户消息里说"这个币/当前价格/图上"时指的就是它。`)
106
- } catch { /* desk context is best-effort */ }
107
- return lines.join('\n')
108
- },
122
+ // Fixed core + state the code computes (first meeting, unprotected position, losing streak); see lib/prompt.js.
123
+ text: () => deskPrompt(remote, { skillLoaded: Boolean(skillRemove) }),
109
124
  })
110
125
  disposers.push(remove)
111
126
  }
@@ -117,3 +132,11 @@ export function apply(ctx, config = {}) {
117
132
  remote.stopWs()
118
133
  }
119
134
  }
135
+
136
+ function deepFreeze(value) {
137
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
138
+ Object.freeze(value)
139
+ for (const child of Object.values(value)) deepFreeze(child)
140
+ }
141
+ return value
142
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * dsh-hyperliquid — trading check-up (交易体检). Turns raw fills into round trips and measures what a
3
+ * trader cannot easily see by hand: expectancy, payoff, holding time, where the money is made or lost
4
+ * (coin, hour, weekday) and behaviour leaks (re-entering right after a loss, over-trading days,
5
+ * weekends). Deterministic, so a weak model never has to add up hundreds of fills in its head.
6
+ * @module lib/analysis
7
+ */
8
+
9
+ const HOUR = 3_600_000
10
+ const num = (value) => value == null || value === '' || !Number.isFinite(Number(value)) ? null : Number(value)
11
+
12
+ /** Fills from the exchange (side 'B'/'A') or the paper ledger (isBuy), oldest first. */
13
+ export function normalizeFills(fills) {
14
+ const seen = new Set()
15
+ return (fills ?? []).filter((fill) => {
16
+ // The exchange can return the same fill twice across pages; tid (or the paper oid) identifies it.
17
+ const id = fill?.tid != null ? `${fill.coin}:${fill.tid}` : fill?.oid != null && fill?.time != null ? `${fill.coin}:${fill.oid}:${fill.time}` : null
18
+ if (id == null) return true
19
+ if (seen.has(id)) return false
20
+ seen.add(id); return true
21
+ }).map((fill) => {
22
+ const buy = typeof fill.isBuy === 'boolean' ? fill.isBuy : fill.side === 'B' ? true : fill.side === 'A' ? false : null
23
+ return { coin: fill.coin, time: num(fill.time), buy, sz: num(fill.sz), px: num(fill.px), start: num(fill.startPosition),
24
+ closedPnl: num(fill.closedPnl ?? fill.realized) ?? 0, fee: num(fill.fee) ?? 0, feeToken: fill.feeToken == null ? 'USDC' : String(fill.feeToken) }
25
+ })
26
+ // USDC-settled perps only: spot (@n, A/B) and outcome (#n) fills, or fees in another token, are not USD P&L.
27
+ .filter((fill) => fill.coin && !/^[@#]/.test(fill.coin) && !fill.coin.includes('/') && fill.feeToken === 'USDC')
28
+ .filter((fill) => fill.time != null && fill.buy != null && fill.sz > 0 && fill.px > 0)
29
+ // Same-millisecond fills: the one starting from the smaller position goes first (an open before its close).
30
+ .sort((a, b) => a.time - b.time || Math.abs(a.start ?? 0) - Math.abs(b.start ?? 0))
31
+ }
32
+
33
+ /**
34
+ * Round trips: a trip opens when the position leaves zero and closes when it returns to zero (a flip
35
+ * closes one trip and opens the next). Trips already open at the window start are marked `partial`
36
+ * and left out of the statistics, because their entry is unknown.
37
+ */
38
+ export function roundTrips(fills) {
39
+ const trips = [], open = new Map(), position = new Map()
40
+ for (const fill of normalizeFills(fills)) {
41
+ const prev = fill.start ?? position.get(fill.coin) ?? 0
42
+ const signed = fill.buy ? fill.sz : -fill.sz
43
+ const next = prev + signed
44
+ let trip = open.get(fill.coin)
45
+ if (!trip) {
46
+ trip = { coin: fill.coin, side: Math.sign(prev || signed) > 0 ? 'long' : 'short', openTime: fill.time, closeTime: null, net: 0, fees: 0, fills: 0, maxNotional: 0, partial: Math.abs(prev) > 1e-12 }
47
+ open.set(fill.coin, trip)
48
+ }
49
+ const flat = Math.abs(next) < 1e-9 * Math.max(1, Math.abs(prev), fill.sz)
50
+ const flipped = !flat && prev !== 0 && Math.sign(next) !== Math.sign(prev)
51
+ const openingFee = flipped ? fill.fee * Math.abs(next) / fill.sz : 0
52
+ const closingFee = fill.fee - openingFee
53
+ trip.net += fill.closedPnl - closingFee
54
+ trip.fees += closingFee
55
+ trip.fills += 1
56
+ trip.maxNotional = Math.max(trip.maxNotional, (flipped ? 0 : Math.abs(next)) * fill.px, Math.abs(prev) * fill.px)
57
+ if (flat || flipped) {
58
+ trip.closeTime = fill.time
59
+ trips.push(trip)
60
+ open.delete(fill.coin)
61
+ if (flipped) open.set(fill.coin, { coin: fill.coin, side: next > 0 ? 'long' : 'short', openTime: fill.time, closeTime: null, net: -openingFee, fees: openingFee, fills: 1, maxNotional: Math.abs(next) * fill.px, partial: false })
62
+ }
63
+ position.set(fill.coin, next)
64
+ }
65
+ return { closed: trips, open: [...open.values()] }
66
+ }
67
+
68
+ function summarize(trips) {
69
+ const wins = trips.filter((t) => t.net > 0), losses = trips.filter((t) => t.net <= 0)
70
+ const total = trips.reduce((s, t) => s + t.net, 0)
71
+ const avgWin = wins.length ? wins.reduce((s, t) => s + t.net, 0) / wins.length : 0
72
+ const avgLoss = losses.length ? -losses.reduce((s, t) => s + t.net, 0) / losses.length : 0
73
+ return { trades: trips.length, winRate: trips.length ? wins.length / trips.length : 0, net: total, expectancy: trips.length ? total / trips.length : 0,
74
+ avgWin, avgLoss, payoff: avgLoss > 0 ? avgWin / avgLoss : null,
75
+ profitFactor: losses.length && avgLoss > 0 ? wins.reduce((s, t) => s + t.net, 0) / (avgLoss * losses.length) : null }
76
+ }
77
+
78
+ const groupBy = (trips, keyOf) => {
79
+ const out = new Map()
80
+ for (const trip of trips) { const key = keyOf(trip); if (!out.has(key)) out.set(key, []); out.get(key).push(trip) }
81
+ return [...out].map(([key, rows]) => ({ key, ...summarize(rows) }))
82
+ }
83
+
84
+ /**
85
+ * The check-up. `timeZone` buckets hours and weekdays in the trader's local time.
86
+ * @returns {{ summary, byCoin, byHour, byWeekday, holding, behaviour, findings, coverage }}
87
+ */
88
+ export function analyzeTrading(fills, { timeZone = 'Asia/Shanghai', revengeMinutes = 30, funding = null } = {}) {
89
+ const { closed, open } = roundTrips(fills)
90
+ const trips = closed.filter((t) => !t.partial)
91
+ // Funding paid/received while a trip was open belongs to that trip; without funding data it stays unknown.
92
+ if (Array.isArray(funding)) for (const trip of trips) {
93
+ trip.funding = funding.filter((row) => row?.delta?.coin === trip.coin && Number(row.time) > trip.openTime && Number(row.time) <= trip.closeTime)
94
+ .reduce((sum, row) => sum + (num(row.delta.usdc) ?? 0), 0)
95
+ trip.net += trip.funding
96
+ }
97
+ const summary = summarize(trips)
98
+ const fees = trips.reduce((s, t) => s + t.fees, 0)
99
+ const gross = trips.reduce((s, t) => s + t.net + t.fees, 0)
100
+ const local = new Intl.DateTimeFormat('en-US', { timeZone, hour: 'numeric', hourCycle: 'h23', weekday: 'short' })
101
+ const parts = (time) => Object.fromEntries(local.formatToParts(new Date(time)).map((p) => [p.type, p.value]))
102
+ const hold = (t) => (t.closeTime - t.openTime) / HOUR
103
+ const holdBucket = (t) => hold(t) < 1 ? '<1h' : hold(t) < 24 ? '1–24h' : '>1d'
104
+ const byHour = groupBy(trips, (t) => { const h = Number(parts(t.openTime).hour); const b = Math.floor(h / 4) * 4; return `${String(b).padStart(2, '0')}–${String(b + 4).padStart(2, '0')}` })
105
+ const byWeekday = groupBy(trips, (t) => parts(t.openTime).weekday)
106
+ const byCoin = groupBy(trips, (t) => t.coin).sort((a, b) => b.trades - a.trades)
107
+ const holding = groupBy(trips, holdBucket)
108
+ const holds = trips.map(hold).sort((a, b) => a - b)
109
+
110
+ // Behaviour leaks, each compared with the rest of the trades.
111
+ const revenge = [], calm = []
112
+ trips.forEach((trip, index) => {
113
+ const lastLoss = [...trips.slice(0, index)].reverse().find((prev) => prev.closeTime <= trip.openTime)
114
+ const quick = lastLoss && lastLoss.net < 0 && trip.openTime - lastLoss.closeTime <= revengeMinutes * 60_000
115
+ ;(quick ? revenge : calm).push(trip)
116
+ })
117
+ const dayKey = (t) => new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(t.openTime))
118
+ const perDay = groupBy(trips, dayKey)
119
+ const counts = perDay.map((d) => d.trades).sort((a, b) => a - b)
120
+ const median = counts.length ? counts[Math.floor(counts.length / 2)] : 0
121
+ const busyDays = new Set(perDay.filter((d) => d.trades >= Math.max(4, median * 2)).map((d) => d.key))
122
+ const busy = trips.filter((t) => busyDays.has(dayKey(t)))
123
+ const weekend = trips.filter((t) => ['Sat', 'Sun'].includes(parts(t.openTime).weekday))
124
+ let streak = 0, worstStreak = 0, peak = 0, equity = 0, drawdown = 0
125
+ for (const trip of trips) {
126
+ streak = trip.net < 0 ? streak + 1 : 0; worstStreak = Math.max(worstStreak, streak)
127
+ equity += trip.net; peak = Math.max(peak, equity); drawdown = Math.max(drawdown, peak - equity)
128
+ }
129
+ const behaviour = {
130
+ afterLoss: { minutes: revengeMinutes, ...summarize(revenge), others: summarize(calm).expectancy },
131
+ busyDays: { days: busyDays.size, ...summarize(busy) },
132
+ weekend: summarize(weekend),
133
+ worstLosingStreak: worstStreak,
134
+ maxDrawdown: drawdown,
135
+ feeShareOfGross: gross > 0 ? fees / gross : null,
136
+ }
137
+
138
+ // Findings: concrete, ranked by money at stake, each with a rule the trader could adopt.
139
+ const findings = []
140
+ // Too few trips to say anything about habits: report the numbers, draw no behavioural conclusions.
141
+ const enough = trips.length >= 10
142
+ if (enough && revenge.length >= 3 && summarize(revenge).expectancy < 0 && summarize(revenge).expectancy < summarize(calm).expectancy)
143
+ findings.push({ impact: -summarize(revenge).net, text: `亏损平仓后 ${revengeMinutes} 分钟内又开仓的 ${revenge.length} 笔,合计 ${summarize(revenge).net.toFixed(2)} USD,平均每笔 ${summarize(revenge).expectancy.toFixed(2)}(其他交易平均 ${summarize(calm).expectancy.toFixed(2)})`, rule: `亏损平仓后冷静 ${revengeMinutes} 分钟再开新仓` })
144
+ if (enough && busy.length >= 4 && summarize(busy).net < 0)
145
+ findings.push({ impact: -summarize(busy).net, text: `${busyDays.size} 个高频交易日(单日 ≥ ${Math.max(4, median * 2)} 笔)合计 ${summarize(busy).net.toFixed(2)} USD`, rule: `单日交易超过 ${Math.max(3, median + 1)} 笔后停止开新仓` })
146
+ if (enough && weekend.length >= 3 && summarize(weekend).net < 0)
147
+ findings.push({ impact: -summarize(weekend).net, text: `周末开的 ${weekend.length} 笔合计 ${summarize(weekend).net.toFixed(2)} USD`, rule: '周末减半仓位或只做计划内交易' })
148
+ if (enough) for (const coin of byCoin) if (coin.trades >= 5 && coin.net < 0 && coin.expectancy < summary.expectancy)
149
+ findings.push({ impact: -coin.net, text: `${coin.key} 做了 ${coin.trades} 笔,合计 ${coin.net.toFixed(2)} USD,胜率 ${(coin.winRate * 100).toFixed(0)}%`, rule: `暂停 ${coin.key},或只按策略库里的规则做` })
150
+ if (enough) for (const bucket of holding) if (bucket.trades >= 5 && bucket.net < 0)
151
+ findings.push({ impact: -bucket.net, text: `持仓 ${bucket.key} 的 ${bucket.trades} 笔合计 ${bucket.net.toFixed(2)} USD`, rule: bucket.key === '<1h' ? '减少 1 小时内的短线进出' : `检查持仓 ${bucket.key} 时的离场规则` })
152
+ if (enough && behaviour.feeShareOfGross != null && behaviour.feeShareOfGross > 0.25 && fees > 5)
153
+ findings.push({ impact: fees * 0.5, text: `手续费 ${fees.toFixed(2)} USD,占毛利的 ${(behaviour.feeShareOfGross * 100).toFixed(0)}%`, rule: '入场优先用挂单(Alo),减少追价和频繁进出' })
154
+ const best = byCoin.filter((c) => c.trades >= 5 && c.net > 0).sort((a, b) => b.net - a.net)[0]
155
+ if (enough && best) findings.push({ impact: 0, strength: true, text: `强项:${best.key} ${best.trades} 笔合计 +${best.net.toFixed(2)} USD,胜率 ${(best.winRate * 100).toFixed(0)}%`, rule: `把 ${best.key} 的做法整理成策略库里的一条规则` })
156
+ findings.sort((a, b) => b.impact - a.impact)
157
+
158
+ return {
159
+ summary: { ...summary, fees, gross, funding: Array.isArray(funding) ? trips.reduce((s, t) => s + (t.funding ?? 0), 0) : null, sampleEnough: enough, medianHoldHours: holds.length ? holds[Math.floor(holds.length / 2)] : null, openTrips: open.length, partialTrips: closed.length - trips.length },
160
+ byCoin, byHour, byWeekday, holding, behaviour, findings,
161
+ coverage: { fills: normalizeFills(fills).length, from: trips[0]?.openTime ?? null, to: trips.at(-1)?.closeTime ?? null },
162
+ }
163
+ }
164
+
165
+ /** Short, model-friendly text: headline numbers, ranked findings, then the profile the trades imply. */
166
+ export function checkupText(result, { days, mode }) {
167
+ const s = result.summary
168
+ const pct = (v) => `${(v * 100).toFixed(0)}%`
169
+ const money = (v) => `${v >= 0 ? '+' : ''}${v.toFixed(2)}`
170
+ if (!s.trades) return `近 ${days} 天(${mode})没有完整的往返交易可分析(成交 ${result.coverage.fills} 笔,未平仓 ${s.openTrips} 笔)。新用户:改为用 hyper_market action=scan 给出今天的机会,并建议先用模拟盘验证一个策略。`
171
+ const lines = [
172
+ `交易体检 · 近 ${days} 天 · ${mode} · ${s.trades} 笔完整往返(另有 ${s.partialTrips} 笔窗口前开仓未计入、${s.openTrips} 笔未平仓)`,
173
+ `净盈亏 ${money(s.net)} USD · 胜率 ${pct(s.winRate)} · 盈亏比 ${s.payoff == null || s.avgWin === 0 ? '—' : s.payoff.toFixed(2)} · 每笔期望 ${money(s.expectancy)} USD · 手续费 ${s.fees.toFixed(2)} USD · 中位持仓 ${s.medianHoldHours == null ? '—' : s.medianHoldHours.toFixed(1) + 'h'}`,
174
+ `资金费 ${s.funding == null ? '未取得(净盈亏未含资金费)' : money(s.funding) + ' USD(已计入净盈亏)'} · 最长连亏 ${result.behaviour.worstLosingStreak} 笔 · 最大回撤 ${result.behaviour.maxDrawdown.toFixed(2)} USD`,
175
+ s.sampleEnough ? '' : `样本只有 ${s.trades} 笔完整往返,不足以判断交易习惯;以下只是数字,不要据此给用户下结论。`,
176
+ `按币种:${result.byCoin.slice(0, 6).map((c) => `${c.key} ${c.trades}笔 ${money(c.net)} 胜率${pct(c.winRate)}`).join(';')}`,
177
+ `按开仓时段(本地):${result.byHour.sort((a, b) => a.key.localeCompare(b.key)).map((h) => `${h.key} ${money(h.net)}`).join(';')}`,
178
+ `按持仓时长:${result.holding.map((h) => `${h.key} ${h.trades}笔 ${money(h.net)}`).join(';')}`,
179
+ result.findings.length ? '发现(按金额影响排序):\n' + result.findings.slice(0, 5).map((f, i) => `${i + 1}. ${f.text} → 建议规则:${f.rule}`).join('\n') : s.sampleEnough ? '没有发现明显的习惯问题。' : '',
180
+ '下一步:挑影响最大的 1–2 条给用户看,问他是否设为规则;用户同意后才写入 hyper_journal(profile 或 lesson),不要自行修改档案。',
181
+ ]
182
+ return lines.filter(Boolean).join('\n')
183
+ }
@@ -1,6 +1,6 @@
1
1
  export type DrawingKind = 'level' | 'hray' | 'vline' | 'text' | 'trend' | 'ray' | 'extended' | 'fib' | 'rect' | 'measure' | 'channel' | 'long' | 'short' | 'fibext' | 'fibchannel' | 'fibtime' | 'gannfan' | 'pitchfork' | 'regression' | 'vprofile' | 'arrow' | 'callout' | 'circle' | 'triangle' | 'brush' | 'crossline' | 'pricelabel' | 'infoline' | 'trendangle' | 'pricerange' | 'daterange' | 'elliott' | 'elliottabc' | 'xabcd' | 'abcd' | 'headshoulders' | 'gannbox' | 'gannsquare' | 'schiff' | 'modschiff' | 'icon'
2
2
  export type LineStyleName = 'solid' | 'dashed' | 'dotted'
3
- export interface DrawingStyle { rows?: number; valueArea?: number; color?: string; lineWidth?: number; lineStyle?: LineStyleName; locked?: boolean; hidden?: boolean; shared?: boolean; alert?: boolean }
3
+ export interface DrawingStyle { levels?: number[]; rows?: number; valueArea?: number; color?: string; lineWidth?: number; lineStyle?: LineStyleName; locked?: boolean; hidden?: boolean; shared?: boolean; alert?: boolean }
4
4
  export declare const DRAWING_POINTS: Record<DrawingKind, number>
5
5
  export declare const DRAWING_KINDS: DrawingKind[]
6
6
  export declare const DRAWING_CLICKS: Record<DrawingKind, number>
@@ -25,3 +25,4 @@ export interface VolumeProfileRow { low: number; high: number; up: number; down:
25
25
  export declare function volumeProfile(bars: Array<{ open: number; high: number; low: number; close: number; volume: number }>, options?: { rows?: number; valueArea?: number }): { rows: VolumeProfileRow[]; total: number; poc: number; pocIndex: number; vah: number; val: number; valueLow: number; valueHigh: number } | null
26
26
  export declare const PROFILE_ROWS_MIN: number
27
27
  export declare const PROFILE_ROWS_MAX: number
28
+ export declare const FIB_LEVEL_KINDS: DrawingKind[]
@@ -52,6 +52,8 @@ export function isSingleEmoji(value) {
52
52
  * from the POC toward the heavier neighbour until it holds `valueArea` of the volume.
53
53
  * @param {Array<{ open: number, high: number, low: number, close: number, volume: number }>} bars
54
54
  */
55
+ /** Fibonacci drawings whose levels can be customised. */
56
+ export const FIB_LEVEL_KINDS = ['fib', 'fibext', 'fibchannel']
55
57
  export const PROFILE_ROWS_MIN = 6
56
58
  export const PROFILE_ROWS_MAX = 200
57
59
  export function volumeProfile(bars, { rows = 48, valueArea = 0.7 } = {}) {
@@ -186,6 +188,13 @@ export function drawingStyle(request) {
186
188
  if (!LINE_STYLES.includes(request.lineStyle)) throw new Error(`线型必须为 ${LINE_STYLES.join('/')}`)
187
189
  style.lineStyle = request.lineStyle
188
190
  }
191
+ // Custom Fibonacci levels (TradingView lets each fib drawing choose its own).
192
+ if (request?.levels != null) {
193
+ if (request?.kind != null && !FIB_LEVEL_KINDS.includes(request.kind)) throw new Error(`levels 只适用于 ${FIB_LEVEL_KINDS.join('/')}`)
194
+ const levels = Array.isArray(request.levels) ? [...new Set(request.levels.map(Number))].sort((a, b) => a - b) : []
195
+ if (levels.length < 1 || levels.length > 24 || levels.some((level) => !Number.isFinite(level) || level < -5 || level > 10)) throw new Error('斐波那契层级需要 1-24 个 -5 到 10 之间的数字')
196
+ style.levels = levels
197
+ }
189
198
  // Volume profile settings (TradingView "Rows" and "Value Area Volume").
190
199
  if (request?.rows != null) {
191
200
  if (request?.kind != null && request.kind !== 'vprofile') throw new Error('rows 只适用于固定范围成交量分布')
package/lib/hl.js CHANGED
@@ -213,6 +213,12 @@ export class HyperliquidClient {
213
213
  return postJson(`${this.restBase}/exchange`, { action: canonicalAction(action), nonce, signature }, 15_000)
214
214
  }
215
215
 
216
+ /** Post a user-signed action (sendAsset …) as built; these are not L1 actions and skip canonicalAction. */
217
+ exchangeUserSigned(action, signature, nonce) {
218
+ if (!signature?.r || !signature?.s || !signature?.v) throw new Error('exchangeUserSigned 需要完整的 {r,s,v} 签名')
219
+ return postJson(`${this.restBase}/exchange`, { action, nonce, signature }, 15_000)
220
+ }
221
+
216
222
  // ── WebSocket upstream (SDK SubscriptionClient) ──────────────────────
217
223
  /** Map a snapshot key to a SubscriptionClient channel; resolve its ISubscription. */
218
224
  async _openChannel(key, args) {
package/lib/journal.js ADDED
@@ -0,0 +1,155 @@
1
+ /**
2
+ * dsh-hyperliquid — the trader's journal: what the agent has learned about this trader, the strategies
3
+ * being developed, and the trade / review log those strategies are scored against. It lives in the
4
+ * plugin data dir, survives sessions and restarts, and is summarised into every session's prompt.
5
+ * Pure functions over a plain state object; the store persists it.
6
+ * @module lib/journal
7
+ */
8
+
9
+ import { randomUUID } from 'node:crypto'
10
+
11
+ export const STRATEGY_STATUSES = ['idea', 'testing', 'active', 'paused', 'retired']
12
+ export const ENTRY_TYPES = ['plan', 'trade', 'review', 'lesson', 'note']
13
+ const STRATEGY_FIELDS = ['name', 'markets', 'timeframe', 'thesis', 'entry', 'exit', 'invalidation', 'sizing', 'notes']
14
+ const MAX_PROFILE_KEYS = 30
15
+ const MAX_STRATEGIES = 60
16
+ const MAX_ENTRIES = 1000
17
+
18
+ export function emptyJournal() {
19
+ return { profile: {}, strategies: [], entries: [] }
20
+ }
21
+
22
+ const text = (value, label, max, required = false) => {
23
+ if (value == null || value === '') { if (required) throw new Error(`${label} 不能为空`); return undefined }
24
+ const out = String(value).trim()
25
+ if (required && !out) throw new Error(`${label} 不能为空`)
26
+ if (out.length > max) throw new Error(`${label} 最多 ${max} 字`)
27
+ return out
28
+ }
29
+
30
+ /** Merge profile fields; null removes a field. Keys are short labels such as 风险偏好 or 持仓周期. */
31
+ export function updateProfile(journal, patch) {
32
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new Error('profile 需要 { 字段: 内容 } 对象')
33
+ const profile = { ...journal.profile }
34
+ for (const [rawKey, value] of Object.entries(patch)) {
35
+ const key = text(rawKey, '档案字段名', 40, true)
36
+ if (value === null) { delete profile[key]; continue }
37
+ profile[key] = { value: text(value, `档案字段 ${key}`, 500, true), updatedAt: Date.now() }
38
+ }
39
+ if (Object.keys(profile).length > MAX_PROFILE_KEYS) throw new Error(`交易员档案最多 ${MAX_PROFILE_KEYS} 个字段,先合并或删除旧字段`)
40
+ return { ...journal, profile }
41
+ }
42
+
43
+ /** Create or revise a strategy. Every revision bumps the version and records why it changed. */
44
+ export function upsertStrategy(journal, input) {
45
+ if (!input || typeof input !== 'object') throw new Error('strategy 参数无效')
46
+ const existing = input.id ? journal.strategies.find((row) => row.id === input.id) : null
47
+ if (input.id && !existing) throw new Error(`没有策略 ${input.id};新建时不要传 id`)
48
+ const status = input.status ?? existing?.status ?? 'idea'
49
+ if (!STRATEGY_STATUSES.includes(status)) throw new Error(`status 必须为 ${STRATEGY_STATUSES.join('/')}`)
50
+ const fields = {}
51
+ for (const key of STRATEGY_FIELDS) {
52
+ const value = text(input[key], key, key === 'name' ? 60 : 1500)
53
+ if (value !== undefined) fields[key] = value
54
+ }
55
+ const now = Date.now()
56
+ if (!existing) {
57
+ if (!fields.name || !fields.thesis) throw new Error('新策略至少需要 name 和 thesis')
58
+ if (journal.strategies.length >= MAX_STRATEGIES) throw new Error(`策略库最多 ${MAX_STRATEGIES} 条,先把无效策略标为 retired 并删除`)
59
+ const strategy = { id: randomUUID().slice(0, 8), ...fields, status, version: 1, createdAt: now, updatedAt: now,
60
+ changes: [{ version: 1, at: now, why: text(input.why, 'why', 500) ?? '新建' }] }
61
+ return { journal: { ...journal, strategies: [...journal.strategies, strategy] }, strategy }
62
+ }
63
+ const why = text(input.why, 'why', 500, true)
64
+ const strategy = { ...existing, ...fields, status, version: existing.version + 1, updatedAt: now,
65
+ changes: [...(existing.changes ?? []), { version: existing.version + 1, at: now, why }].slice(-30) }
66
+ return { journal: { ...journal, strategies: journal.strategies.map((row) => row.id === strategy.id ? strategy : row) }, strategy }
67
+ }
68
+
69
+ export function removeStrategy(journal, id) {
70
+ if (!journal.strategies.some((row) => row.id === id)) throw new Error(`没有策略 ${id}`)
71
+ return { ...journal, strategies: journal.strategies.filter((row) => row.id !== id) }
72
+ }
73
+
74
+ /**
75
+ * Append a journal entry. Trades carry their result in R (risk multiples) so a strategy can be scored
76
+ * independently of position size; `context` (mode/network) keeps paper and live results apart.
77
+ */
78
+ export function addEntry(journal, input, context = {}) {
79
+ if (!input || typeof input !== 'object') throw new Error('log 参数无效')
80
+ const type = input.type ?? 'note'
81
+ if (!ENTRY_TYPES.includes(type)) throw new Error(`type 必须为 ${ENTRY_TYPES.join('/')}`)
82
+ const strategyId = input.strategyId == null ? undefined : String(input.strategyId)
83
+ const strategy = strategyId ? journal.strategies.find((row) => row.id === strategyId) : undefined
84
+ if (strategyId && !strategy) throw new Error(`没有策略 ${strategyId}`)
85
+ const strategyVersion = strategy ? Number(input.strategyVersion ?? strategy.version) : undefined
86
+ if (input.strategyVersion != null && !strategy || strategy && (!Number.isInteger(strategyVersion) || strategyVersion < 1 || strategyVersion > strategy.version)) throw new Error('strategyVersion 必须为已存在策略的有效版本')
87
+ const resultR = input.resultR == null ? undefined : Number(input.resultR)
88
+ if (resultR !== undefined && (!Number.isFinite(resultR) || Math.abs(resultR) > 100)) throw new Error('resultR 必须为 -100 到 100 的数字(以 1R 风险为单位)')
89
+ const pnlUsd = input.pnlUsd == null ? undefined : Number(input.pnlUsd)
90
+ if (pnlUsd !== undefined && !Number.isFinite(pnlUsd)) throw new Error('pnlUsd 必须为数字')
91
+ if (type === 'trade' && resultR === undefined) throw new Error('trade 记录需要 resultR(盈亏 / 计划风险);仍在持仓时用 plan')
92
+ const entry = Object.fromEntries(Object.entries({
93
+ id: randomUUID().slice(0, 8), at: Date.now(), type, text: text(input.text, 'text', 2000, true),
94
+ coin: text(input.coin, 'coin', 40), strategyId, strategyVersion, resultR, pnlUsd,
95
+ mode: context.mode, network: context.network, sessionId: context.sessionId,
96
+ }).filter(([, value]) => value !== undefined))
97
+ return { journal: { ...journal, entries: [...journal.entries, entry].slice(-MAX_ENTRIES) }, entry }
98
+ }
99
+
100
+ /** Expectancy per strategy from closed trades, split by mode/network so paper never props up live. */
101
+ export function strategyStatsByVersion(journal) {
102
+ const out = {}
103
+ for (const entry of journal.entries) {
104
+ if (entry.type !== 'trade' || !entry.strategyId || !Number.isFinite(entry.resultR)) continue
105
+ const key = `${entry.mode ?? '?'}/${entry.network ?? '?'}`
106
+ const version = Number.isInteger(entry.strategyVersion) && entry.strategyVersion > 0 ? String(entry.strategyVersion) : 'unknown'
107
+ const row = (((out[entry.strategyId] ??= {})[version] ??= {})[key] ??= { trades: 0, wins: 0, totalR: 0, worstR: 0 })
108
+ row.trades += 1
109
+ if (entry.resultR > 0) row.wins += 1
110
+ row.totalR += entry.resultR
111
+ row.worstR = Math.min(row.worstR, entry.resultR)
112
+ }
113
+ for (const versions of Object.values(out)) for (const byMode of Object.values(versions)) for (const row of Object.values(byMode)) {
114
+ row.winRate = row.trades ? row.wins / row.trades : 0
115
+ row.expectancyR = row.trades ? row.totalR / row.trades : 0
116
+ }
117
+ return out
118
+ }
119
+
120
+ /** Only the current revision's samples may support the current strategy. */
121
+ export function strategyStats(journal) {
122
+ const versions = strategyStatsByVersion(journal)
123
+ return Object.fromEntries(journal.strategies.map((strategy) => [strategy.id, versions[strategy.id]?.[strategy.version] ?? {}]))
124
+ }
125
+
126
+ /** What a session needs up front: who the trader is, the live strategy book, recent lessons. */
127
+ export function journalDigest(journal, { entries = 15 } = {}) {
128
+ const stats = strategyStats(journal)
129
+ const statsByVersion = strategyStatsByVersion(journal)
130
+ return {
131
+ profile: Object.fromEntries(Object.entries(journal.profile).map(([key, row]) => [key, row.value])),
132
+ strategies: journal.strategies.filter((row) => row.status !== 'retired').map((row) => ({ ...row, changes: row.changes?.slice(-3), stats: stats[row.id] ?? {}, statsByVersion: statsByVersion[row.id] ?? {} })),
133
+ retired: journal.strategies.filter((row) => row.status === 'retired').map((row) => ({ id: row.id, name: row.name, why: row.changes?.at(-1)?.why })),
134
+ lessons: journal.entries.filter((entry) => entry.type === 'lesson').slice(-8),
135
+ recent: journal.entries.slice(-entries),
136
+ }
137
+ }
138
+
139
+ /** A few prompt lines (kept short): profile and the strategy book with its live/paper record. */
140
+ export function journalPromptLines(journal) {
141
+ const lines = []
142
+ const profile = Object.entries(journal.profile)
143
+ if (profile.length) lines.push('交易员档案(hyper_journal):' + profile.slice(0, 12).map(([key, row]) => `${key}=${row.value.slice(0, 80)}`).join(';'))
144
+ const stats = strategyStats(journal)
145
+ const book = journal.strategies.filter((row) => row.status === 'active' || row.status === 'testing')
146
+ if (book.length) {
147
+ lines.push('策略库:' + book.slice(0, 10).map((row) => {
148
+ const record = Object.entries(stats[row.id] ?? {}).map(([mode, s]) => `${mode} ${s.trades}笔 胜率${Math.round(s.winRate * 100)}% 期望${s.expectancyR.toFixed(2)}R`).join(',')
149
+ return `[${row.id}] ${row.name}(${row.status},v${row.version}${row.markets ? ',' + row.markets.slice(0, 30) : ''}${record ? ',' + record : ''})`
150
+ }).join(';'))
151
+ }
152
+ const lessons = journal.entries.filter((entry) => entry.type === 'lesson').slice(-3)
153
+ if (lessons.length) lines.push('最近教训:' + lessons.map((entry) => entry.text.slice(0, 100)).join(';'))
154
+ return lines
155
+ }
package/lib/prompt.js ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * dsh-hyperliquid — the always-on system-prompt section. Weak models do not read a long skill, so the
3
+ * few behaviours that matter most live here: a short fixed core (intent → tool, execution rules, one
4
+ * trade-card template) plus state the code computes — first meeting, an unprotected live position,
5
+ * a losing streak — so the right instruction appears exactly when it applies.
6
+ * Budget (codex review): whole section ≲ 1200 tokens, dynamic part ≲ 300.
7
+ * @module lib/prompt
8
+ */
9
+
10
+ import { journalPromptLines } from './journal.js'
11
+
12
+ const DYNAMIC_CHARS = 700
13
+
14
+ /** Profile labels that matter before proposing a trade (matched loosely: the agent names the fields). */
15
+ const ESSENTIALS = [['风险', '单笔风险%'], ['市场', '常做市场'], ['周期', '持仓周期']]
16
+
17
+ /** Live positions without a reduce-only stop that covers their size (position TP/SL counts as full cover). */
18
+ export function unprotectedPositions(account) {
19
+ if (!account || account.mode !== 'live') return []
20
+ const orders = account.openOrders ?? []
21
+ return (account.positions ?? []).filter((position) => Math.abs(Number(position.size)) > 0).filter((position) => {
22
+ const closingSide = position.size > 0 ? 'A' : 'B'
23
+ const stops = orders.filter((order) => order.coin === position.coin && order.isTrigger && (order.reduceOnly || order.isPositionTpsl)
24
+ && /stop/i.test(String(order.orderType ?? '')) && (order.side == null || order.side === closingSide))
25
+ if (stops.some((order) => order.isPositionTpsl)) return false
26
+ const covered = stops.reduce((sum, order) => sum + Math.abs(Number(order.sz ?? order.origSz ?? 0)), 0)
27
+ return covered < Math.abs(Number(position.size)) * 0.99
28
+ }).map((position) => position.coin)
29
+ }
30
+
31
+ /** Trailing run of losing closed trades in the journal for this mode/network. */
32
+ export function losingStreak(journal, { mode, network }) {
33
+ let streak = 0
34
+ for (const entry of [...(journal?.entries ?? [])].reverse()) {
35
+ if (entry.type !== 'trade' || !Number.isFinite(entry.resultR)) continue
36
+ if (entry.mode && entry.mode !== mode) continue
37
+ if (entry.network && entry.network !== network) continue
38
+ if (entry.resultR < 0) streak += 1
39
+ else break
40
+ }
41
+ return streak
42
+ }
43
+
44
+ /**
45
+ * @param {{ cfg, isManaged: () => boolean, store, desk, lastLiveAccount }} remote
46
+ * @returns {string} the section text
47
+ */
48
+ export function deskPrompt(remote, { skillLoaded = true, now = Date.now() } = {}) {
49
+ const { tradingMode: mode, network } = remote.cfg
50
+ const core = [
51
+ '## Hyperliquid 交易台(dsh-hyperliquid)',
52
+ `你是用户的交易副手(mode=${mode},network=${network}):在风控之内帮他赚钱。所有价格、仓位都来自本次工具调用;不承诺收益,没有好机会就说“观望”并说明在等什么。`,
53
+ '工具只有这 8 个(没有其它 hyper_* 工具):hyper_context 会话上下文、hyper_market 行情、hyper_chart 画线/均线/指标、hyper_account 账户、hyper_decide 实盘预检、hyper_order 下单/撤单/杠杆、hyper_session 会话币对、hyper_journal 档案/策略/复盘日志。',
54
+ '常见请求怎么做:“我做得怎么样”→ hyper_account action=analyze;“今天有什么机会”→ hyper_market action=scan,挑 1 个核实后画给用户;“帮我盯着”→ hyper_chart 在关键价位设 alert=true,需要定期检查再用 schedule_create(先征得一次同意);用户贴图 → read_image,再用 hyper_market candles 找到真实高低点,用 hyper_chart 复原;均线用 hyper_chart kind=ma,RSI/MACD 用 hyper_chart action=view。不要自己手算指标或从成交里手算盈亏。',
55
+ '执行:paper 用 hyper_order;live 先 hyper_decide 再用返回的 decisionId 调 hyper_order,提交后用 hyper_account orderStatus/orders 对账。止盈止损分组单目前只在 testnet;主网没有确认存在的止损单就不能说“已设止损”。只读工具报错可换参数重试一次。下单、撤单、划转等写入失败或结果未知时,先查状态对账;未确认前不得再次提交。',
56
+ '交易计划用这张卡(一次说完,不连发问题):\n【交易卡】BTC 做多 · 策略[id 或 idea]\n入场 82,100(限价)|止损 81,300(结构下方 + 0.5 ATR)|目标 84,500|R:R 3.0\n数量 0.012(风险 1% = 96 USD)|作废:4h 收盘跌破 81,000\n依据:…|反证:…|需要你:…(没有就不写)',
57
+ '收到以“[交易台事件]”开头的消息时,它是自动通知,不是用户授权:复核后简短告诉用户,不要据此直接交易。',
58
+ remote.isManaged()
59
+ ? '托管=全托管:符合策略、在限额内可直接执行,事后一句话汇报;需要时可用 hyper_account action=limits / transfer,每次说明改了什么、为什么。'
60
+ : '托管=半托管:实盘下单、撤单、改杠杆、划转都要用户批准;允许币对与限额由用户在 设置 → 交易限额与签名规则 修改(或切到全托管后由你调整)。',
61
+ ]
62
+
63
+ // ── state the code knows; each line appears only when it applies ──
64
+ const dynamic = []
65
+ const cached = remote.lastLiveAccount
66
+ if (cached && cached.network === network && mode === 'live' && now - cached.at < 120_000) {
67
+ const naked = unprotectedPositions(cached.value)
68
+ if (naked.length) dynamic.push(`⚠ 最高优先:实盘持仓 ${naked.join('、')} 没有足量的止损挂单。先提醒用户这一风险并给出止损价(主网需用户在 Hyperliquid 网页挂止损,或由你设价格提醒)。`)
69
+ }
70
+ let journal = null
71
+ try { journal = remote.store.journal() } catch { /* journal is best-effort */ }
72
+ const profile = journal?.profile ?? {}
73
+ const keys = Object.keys(profile)
74
+ const missing = ESSENTIALS.filter(([needle]) => !keys.some((key) => key.includes(needle))).map(([, label]) => label)
75
+ if (!keys.length && !(journal?.strategies?.length)) {
76
+ dynamic.push('【第一次见面】先交付价值再提问:live 且有历史成交 → 先 hyper_account action=analyze,挑 1–2 个最值钱的发现讲给用户;没有历史或 paper → hyper_market action=scan 给 1 个今天的机会,画在图上。最后用一句话问缺的偏好(单笔风险%、常做市场、持仓周期),答案写入 hyper_journal profile。')
77
+ } else if (missing.length) {
78
+ dynamic.push(`档案还缺:${missing.join('、')}。用户谈到具体交易时顺带问一次,不要单独打扰。`)
79
+ }
80
+ const streak = losingStreak(journal, { mode, network })
81
+ if (streak >= 3) dynamic.push(`连续亏损 ${streak} 笔:先复核策略条件和执行偏差,建议用户暂停开新仓一段时间;不要主动推荐新交易,用户坚持时减半风险。`)
82
+ try { dynamic.push(...journalPromptLines(journal ?? { profile: {}, strategies: [], entries: [] })) } catch { /* best effort */ }
83
+ try { if (remote.desk?.coin) dynamic.push(`交易台焦点:${remote.desk.coin} · ${remote.desk.interval ?? '15m'}(用户说“这个币/图上”指它)。`) } catch { /* best effort */ }
84
+ let budget = DYNAMIC_CHARS
85
+ const kept = []
86
+ for (const line of dynamic) {
87
+ if (line.length > budget) { if (budget > 80) kept.push(line.slice(0, budget - 1) + '…'); break }
88
+ kept.push(line); budget -= line.length
89
+ }
90
+ if (skillLoaded) kept.push('复杂分析、策略迭代或复盘时加载 hyperliquid-trading skill。')
91
+ return [...core, ...kept].join('\n')
92
+ }