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/CHANGELOG.md +25 -0
- package/README.md +20 -9
- package/client.js +2302 -628
- package/docs/research-review-2026-10-16.md +46 -0
- package/index.js +43 -19
- package/lib/analysis.js +183 -0
- package/lib/chart-drawings.d.ts +7 -2
- package/lib/chart-drawings.js +67 -0
- package/lib/hl.js +6 -0
- package/lib/journal.js +155 -0
- package/lib/prompt.js +92 -0
- package/lib/remote.js +410 -80
- package/lib/review.d.ts +3 -0
- package/lib/review.js +79 -0
- package/lib/risk-settings.js +2 -1
- package/lib/scan.js +91 -0
- package/lib/signing.js +58 -0
- package/lib/skill.js +2 -2
- package/lib/store.js +68 -8
- package/lib/studies.d.ts +13 -0
- package/lib/studies.js +157 -0
- package/lib/tools.js +191 -35
- package/lib/turn-origin.js +46 -0
- package/lib/wake.js +85 -0
- package/lib/workspace-settings.d.ts +3 -0
- package/lib/workspace-settings.js +12 -0
- package/package.json +8 -4
- package/skills/hyperliquid-trading/SKILL.md +106 -27
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# 多周期、指标配置与复盘证据
|
|
2
|
+
|
|
3
|
+
范围按用户选择收敛为三项:复盘可信度、多周期研究、常用指标设置。其余 TradingView 平台功能不作为待办。
|
|
4
|
+
|
|
5
|
+
## 入口与行为
|
|
6
|
+
|
|
7
|
+
- 图表「单图 / 双图研究 / 四图研究」:同一会话标的,不同周期;副图可独立选择 14 种周期。时间视窗按 UTC epoch 联动,光标定位到对应周期包含该时间的 K 线;可分别关闭联动。布局、周期、联动开关和各周期视区按会话保存。
|
|
8
|
+
- 主图编辑画线,其他研究图同步显示该会话标注。每图的点位引用都携带自己的周期、原始 OHLCV 和当前参数计算值。副图只读画线,避免误改。
|
|
9
|
+
- 「指标 → 参数与模板」配置 RSI 长度及阈值、MACD 快慢和信号长度、BOLL 长度和标准差倍数、Stochastic 参数及阈值、ADX/DMI 参数。模板包括已启用指标、全部参数、自定义均线;宿主保存,可跨会话载入、更新和删除。
|
|
10
|
+
- 「数据窗口」显示光标对应的原始 OHLCV、当前启用指标和自定义均线值。未完成预热的指标留空,不填零。配置及开关按会话保存。
|
|
11
|
+
- 「交易复盘」按 7 / 30 / 90 天、当前标的或全部标的读取成交、委托和资金费。展开逐笔记录查看 oid、交易 ID、原始原因、费用、执行偏差及缺口;可定位图表、导出含原始证据与核对结果的 JSON,或交给 AI 复盘。
|
|
12
|
+
- 「交易设置 → 交易限额 → 模拟成交成本」:默认手续费 4.5 bps、不利滑点 5 bps,可配置 0–100 bps;是模拟假设,不是账户实际费率。费用实际扣现金,保证金预检包含费用和滑点损失。仅作用于新成交,逐笔保存当时配置与参考中间价。
|
|
13
|
+
|
|
14
|
+
## 复盘口径
|
|
15
|
+
|
|
16
|
+
交易窗口统计不等于账户收益率,也不等于一笔完整开平仓。订单按 `coin + oid` 匹配,成交优先按 `coin + tid` 去重;模拟成交使用本地唯一 oid。保留原始决策原因,未记录的原因标为缺失,不事后补写为原始依据。
|
|
17
|
+
|
|
18
|
+
核心 USDC 永续:已实现毛盈亏使用成交的 closedPnl,扣费净额减去该窗口所有成交手续费(含开仓手续费)。资金费按现金流正负计入,仅在完整取得窗口资金费时显示含资金费净额。手续费 fee 已含 builderFee,不能重复扣除;不同 feeToken 分开汇总。HIP-3、现货和 Outcome 不并入核心永续的 USDC 净额。平仓成交笔数不是完整往返交易数。
|
|
19
|
+
|
|
20
|
+
缺失 fee、closedPnl 或资金费数据时净额显示未知;历史达到保留上限时显示覆盖缺口。实盘受交易所最近 2000 笔成交 / 历史委托限制;资金费分页使用包含末时间戳的边界并去重,最多 12 页,遇到无法推进、无效记录、请求错误或上限则标为不完整。历史订单没有窗口内成交可能是撤单、未成交、窗口外成交或截断,不据此认定执行失败。状态未知的本地下单需按 cloid 查询 orderStatus。
|
|
21
|
+
|
|
22
|
+
模拟账本保留最多 5000 笔成交与委托;旧版本仅保留 200 笔,达到这一数量后保守提示历史可能截断。旧记录未知费用不追溯补算。不模拟盘口深度、资金费和强平。钱包统计也明确仅覆盖保留成交。
|
|
23
|
+
|
|
24
|
+
## 指标口径与 AI 一致性
|
|
25
|
+
|
|
26
|
+
`lib/studies.js` 是图表、数据窗口、点位引用和 AI 配置指标的共同计算入口。RSI 使用 Wilder 平滑;本次纠正旧图表后续涨跌项未除以周期的错误。MACD 用 SMA 初始化的 EMA;BOLL 使用总体标准差;Stochastic 使用区间最高 / 最低计算原始 %K,再对 %K / %D 做 SMA;ADX 使用 TR、方向运动和 DX 的 Wilder 平滑;OBV 从已加载首根的零基准累积有符号成交量。
|
|
27
|
+
|
|
28
|
+
OBV 绝对值随历史窗口变化;EMA、RSI、ADX 等有递归初始化,对比不同加载窗口时可能存在预热差异。图表含形成中末根,AI `hyper_market indicators` 排除未收盘 K 线,只能在相同参数、相同历史窗口、相同已收盘时点比较。高周期光标匹配到的柱子在低周期当时可能尚未收盘;联动用于研究,不是隔离未来数据的回放器。
|
|
29
|
+
|
|
30
|
+
`hyper_context.workspace` 返回参数和研究布局;`hyper_market indicators.configured` 返回实际参数、各指标值和已收盘时点,兼容字段 RSI/MACD/BOLL 也按配置更新;`hyper_account review` 返回与界面相同的证据和核对结果。策略框架继续使用其明确写出的固定默认条件,不能把自定义参数当作已验证策略。
|
|
31
|
+
|
|
32
|
+
## 验证
|
|
33
|
+
|
|
34
|
+
- `npm test`:新增指标手工金值、RSI 与既有后端对照、资金费分页边界、重复成交、builder fee、不完整费用、数量差异、模拟成本扣账、模板及会话配置恢复、AI 参数一致性。
|
|
35
|
+
- `npm run typecheck` / `npm run build`。
|
|
36
|
+
- `npm run smoke:research`:临时真实 DSH 宿主,四图/双图、光标和时间联动、拖动不引用、参数与模板、数据窗口、点位参数、复盘导出与刷新恢复。
|
|
37
|
+
- `npm run smoke:drawing`:原绘图、图表基础交互及新增研究流程的组合回归。
|
|
38
|
+
|
|
39
|
+
本轮已通过 `npm test`、`npm run typecheck`、`npm run build`、`npm run smoke:chart` 和 `npm run smoke:research`。浏览器验收覆盖模拟费用设置保存、四图/双图、光标与时间联动、拖动不引用、参数化点位引用、90 天复盘证据导出、刷新恢复及模板跨会话应用。图表基础回归覆盖画线坐标、图表类型、日期导航、导出和放大视图。
|
|
40
|
+
|
|
41
|
+
## 口径依据
|
|
42
|
+
|
|
43
|
+
- [Hyperliquid Info endpoint](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint):成交 ID、费用字段、返回上限。
|
|
44
|
+
- [Hyperliquid perpetuals info](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint/perpetuals):userFunding 时间窗和现金流字段。
|
|
45
|
+
- [TradingView Stochastic](https://www.tradingview.com/support/solutions/43000502332-stochastic-stoch/):%K / %D 定义。
|
|
46
|
+
- [TradingView ADX](https://www.tradingview.com/support/solutions/43000589099-average-directional-index-adx/) 与 [OBV](https://www.tradingview.com/support/solutions/43000502593-on-balance-volume-obv/):指标含义与计算口径。
|
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('
|
|
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,23 +119,8 @@ export function apply(ctx, config = {}) {
|
|
|
88
119
|
const remove = systemPrompt.section({
|
|
89
120
|
name: 'hyperliquid-desk',
|
|
90
121
|
order: 500,
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const net = remote.cfg.network
|
|
94
|
-
const lines = [
|
|
95
|
-
'## Hyperliquid 交易台(dsh-hyperliquid)',
|
|
96
|
-
`本 Harness 装有 Hyperliquid 交易台(mode=${mode},network=${net})。用户可在面板看行情并填写交易票据;票据只把意图送入当前会话,所有实际下单只能由你调用工具完成。`,
|
|
97
|
-
'可用工具:hyper_context(会话交易上下文)、hyper_market(详细行情)、hyper_chart(在行情图上画支撑/阻力与趋势线)、hyper_account(账户)、hyper_decide(交易决策预检并返回一次性 decisionId)、hyper_order(执行/撤单/杠杆)、hyper_session(会话视角)。',
|
|
98
|
-
'用户说"当前/界面上这个币"时,先 hyper_context 或 hyper_session status;需要技术判断时用 hyper_market indicators 验证趋势、动量、波动和支撑阻力,可用 hyper_chart 将依据画给用户看。交易前核对行情与账户,说明方向、数量、依据及风险。paper 模式用 hyper_order 执行;live 模式先用 hyper_decide 预检,并用返回的一次性 decisionId 执行 hyper_order。是否可自主执行由宿主配置和钱包策略决定。',
|
|
99
|
-
]
|
|
100
|
-
if (skillRemove) lines.push('复杂的 Hyperliquid 分析、交易或异常对账,请先加载 hyperliquid-trading skill。')
|
|
101
|
-
// Live desk focus: what coin/interval the desk shows right now.
|
|
102
|
-
try {
|
|
103
|
-
const desk = remote.desk ?? {}
|
|
104
|
-
if (desk.coin) lines.push(`当前交易台焦点:${desk.coin} · K线 ${desk.interval ?? '15m'}。用户消息里说"这个币/当前价格/图上"时指的就是它。`)
|
|
105
|
-
} catch { /* desk context is best-effort */ }
|
|
106
|
-
return lines.join('\n')
|
|
107
|
-
},
|
|
122
|
+
// Fixed core + state the code computes (first meeting, unprotected position, losing streak); see lib/prompt.js.
|
|
123
|
+
text: () => deskPrompt(remote, { skillLoaded: Boolean(skillRemove) }),
|
|
108
124
|
})
|
|
109
125
|
disposers.push(remove)
|
|
110
126
|
}
|
|
@@ -116,3 +132,11 @@ export function apply(ctx, config = {}) {
|
|
|
116
132
|
remote.stopWs()
|
|
117
133
|
}
|
|
118
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
|
+
}
|
package/lib/analysis.js
ADDED
|
@@ -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
|
+
}
|
package/lib/chart-drawings.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export type DrawingKind = 'level' | 'hray' | 'vline' | 'text' | 'trend' | 'ray' | 'extended' | 'fib' | 'rect' | 'measure' | 'channel' | 'long' | 'short' | 'fibext' | 'fibchannel' | 'fibtime' | 'gannfan' | 'pitchfork' | 'regression' | 'arrow' | 'callout' | 'circle' | 'triangle' | 'brush' | 'crossline' | 'pricelabel' | 'infoline' | 'trendangle' | 'pricerange' | 'daterange' | 'elliott' | 'elliottabc' | 'xabcd' | 'abcd' | 'headshoulders' | 'gannbox' | 'gannsquare' | 'schiff' | 'modschiff' | 'icon'
|
|
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 { 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>
|
|
@@ -21,3 +21,8 @@ export declare function isIconLabel(label: unknown): boolean
|
|
|
21
21
|
export declare const ALERT_KINDS: DrawingKind[]
|
|
22
22
|
export declare function linePriceAt(mark: unknown, time: number): number | null
|
|
23
23
|
export declare function isSingleEmoji(value: unknown): boolean
|
|
24
|
+
export interface VolumeProfileRow { low: number; high: number; up: number; down: number }
|
|
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
|
+
export declare const PROFILE_ROWS_MIN: number
|
|
27
|
+
export declare const PROFILE_ROWS_MAX: number
|
|
28
|
+
export declare const FIB_LEVEL_KINDS: DrawingKind[]
|
package/lib/chart-drawings.js
CHANGED
|
@@ -14,6 +14,8 @@ export const DRAWING_POINTS = {
|
|
|
14
14
|
long: 3, short: 3,
|
|
15
15
|
// Fibonacci family, Gann, pitchfork, regression.
|
|
16
16
|
fibext: 3, fibchannel: 3, fibtime: 2, gannfan: 2, pitchfork: 3, regression: 2,
|
|
17
|
+
// Fixed range volume profile: the two points only set the time range.
|
|
18
|
+
vprofile: 2,
|
|
17
19
|
// Annotation and shapes. brush is freehand: 0 = variable (BRUSH_MIN..BRUSH_MAX points).
|
|
18
20
|
arrow: 2, callout: 2, circle: 2, triangle: 3, brush: 0,
|
|
19
21
|
// More lines and measures: cross line, price label, info line, trend angle, price-only and date-only ranges.
|
|
@@ -44,6 +46,46 @@ export function isSingleEmoji(value) {
|
|
|
44
46
|
if (typeof value !== 'string' || !value || value.length > 32 || !/\p{Extended_Pictographic}|\p{Regional_Indicator}/u.test(value)) return false
|
|
45
47
|
return [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(value)].length === 1
|
|
46
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Fixed range volume profile (TradingView FRVP): each bar's volume is spread evenly over its high–low
|
|
51
|
+
* range into `rows` price rows, split by bar direction. POC is the heaviest row; the value area grows
|
|
52
|
+
* from the POC toward the heavier neighbour until it holds `valueArea` of the volume.
|
|
53
|
+
* @param {Array<{ open: number, high: number, low: number, close: number, volume: number }>} bars
|
|
54
|
+
*/
|
|
55
|
+
/** Fibonacci drawings whose levels can be customised. */
|
|
56
|
+
export const FIB_LEVEL_KINDS = ['fib', 'fibext', 'fibchannel']
|
|
57
|
+
export const PROFILE_ROWS_MIN = 6
|
|
58
|
+
export const PROFILE_ROWS_MAX = 200
|
|
59
|
+
export function volumeProfile(bars, { rows = 48, valueArea = 0.7 } = {}) {
|
|
60
|
+
const valid = bars.filter((bar) => [bar.open, bar.high, bar.low, bar.close, bar.volume].every(Number.isFinite) && bar.high >= bar.low && bar.volume >= 0)
|
|
61
|
+
if (!valid.length) return null
|
|
62
|
+
const low = Math.min(...valid.map((bar) => bar.low)), high = Math.max(...valid.map((bar) => bar.high))
|
|
63
|
+
// A flat range is one row at that exact price, not a row of invented width above it.
|
|
64
|
+
const count = high > low ? rows : 1
|
|
65
|
+
const step = high > low ? (high - low) / count : 0
|
|
66
|
+
const out = Array.from({ length: count }, (_, index) => ({ low: low + index * step, high: count === 1 ? high : low + (index + 1) * step, up: 0, down: 0 }))
|
|
67
|
+
for (const bar of valid) {
|
|
68
|
+
const side = bar.close >= bar.open ? 'up' : 'down'
|
|
69
|
+
if (count === 1) { out[0][side] += bar.volume; continue }
|
|
70
|
+
if (bar.high === bar.low) { out[Math.min(count - 1, Math.floor((bar.low - low) / step))][side] += bar.volume; continue }
|
|
71
|
+
const first = Math.max(0, Math.floor((bar.low - low) / step)), last = Math.min(count - 1, Math.floor((bar.high - low) / step))
|
|
72
|
+
for (let index = first; index <= last; index += 1) {
|
|
73
|
+
const overlap = Math.min(bar.high, out[index].high) - Math.max(bar.low, out[index].low)
|
|
74
|
+
if (overlap > 0) out[index][side] += bar.volume * overlap / (bar.high - bar.low)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const total = out.reduce((sum, row) => sum + row.up + row.down, 0)
|
|
78
|
+
if (!(total > 0)) return null
|
|
79
|
+
const size = (index) => index >= 0 && index < count ? out[index].up + out[index].down : -1
|
|
80
|
+
let poc = 0
|
|
81
|
+
for (let index = 1; index < count; index += 1) if (size(index) > size(poc)) poc = index
|
|
82
|
+
let lo = poc, hi = poc, held = size(poc)
|
|
83
|
+
while (held < total * valueArea && (lo > 0 || hi < count - 1)) {
|
|
84
|
+
if (size(hi + 1) >= size(lo - 1)) { hi += 1; held += size(hi) } else { lo -= 1; held += size(lo) }
|
|
85
|
+
}
|
|
86
|
+
return { rows: out, total, poc: (out[poc].low + out[poc].high) / 2, pocIndex: poc, vah: out[hi].high, val: out[lo].low, valueLow: lo, valueHigh: hi }
|
|
87
|
+
}
|
|
88
|
+
|
|
47
89
|
/** Gann box divisions on both axes (TradingView defaults). */
|
|
48
90
|
export const GANN_BOX_LEVELS = [0, 0.25, 0.382, 0.5, 0.618, 0.75, 1]
|
|
49
91
|
/** Drawings a price can cross: alerts fire when the mid price crosses them (once, like TradingView). */
|
|
@@ -112,6 +154,11 @@ export function drawingGeometry(kind, request, now = Date.now()) {
|
|
|
112
154
|
} else if (!Array.isArray(raw) || raw.length !== need) throw new Error(`${kind} 需要 ${need} 个 points({ time, price })`)
|
|
113
155
|
const points = raw.map((item, index) => point(item?.time, item?.price, `第 ${index + 1} 个点`))
|
|
114
156
|
if (['trend', 'ray', 'extended', 'channel', 'fibchannel', 'gannfan', 'regression', 'fibtime', 'infoline', 'trendangle'].includes(kind) && points[0].time === points[1].time) throw new Error('前两个点的时间不能相同')
|
|
157
|
+
if (kind === 'vprofile') {
|
|
158
|
+
if (points[0].time === points[1].time) throw new Error('成交量分布需要一个时间区间')
|
|
159
|
+
// Only the time range matters, so either drawing direction is fine.
|
|
160
|
+
if (points[1].time < points[0].time) points.reverse()
|
|
161
|
+
}
|
|
115
162
|
if ((kind === 'regression' || kind === 'fibtime' || kind === 'gannfan') && points[1].time < points[0].time) throw new Error('第二个点必须晚于第一个点')
|
|
116
163
|
if (kind === 'long' || kind === 'short') {
|
|
117
164
|
const [entry, stop, target] = points.map((item) => item.price)
|
|
@@ -141,6 +188,26 @@ export function drawingStyle(request) {
|
|
|
141
188
|
if (!LINE_STYLES.includes(request.lineStyle)) throw new Error(`线型必须为 ${LINE_STYLES.join('/')}`)
|
|
142
189
|
style.lineStyle = request.lineStyle
|
|
143
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
|
+
}
|
|
198
|
+
// Volume profile settings (TradingView "Rows" and "Value Area Volume").
|
|
199
|
+
if (request?.rows != null) {
|
|
200
|
+
if (request?.kind != null && request.kind !== 'vprofile') throw new Error('rows 只适用于固定范围成交量分布')
|
|
201
|
+
const rows = Number(request.rows)
|
|
202
|
+
if (!Number.isInteger(rows) || rows < PROFILE_ROWS_MIN || rows > PROFILE_ROWS_MAX) throw new Error(`成交量分布行数必须为 ${PROFILE_ROWS_MIN}-${PROFILE_ROWS_MAX} 的整数`)
|
|
203
|
+
style.rows = rows
|
|
204
|
+
}
|
|
205
|
+
if (request?.valueArea != null) {
|
|
206
|
+
if (request?.kind != null && request.kind !== 'vprofile') throw new Error('valueArea 只适用于固定范围成交量分布')
|
|
207
|
+
const share = Number(request.valueArea)
|
|
208
|
+
if (!Number.isFinite(share) || share < 0.5 || share > 0.95) throw new Error('价值区比例必须在 0.5-0.95 之间')
|
|
209
|
+
style.valueArea = share
|
|
210
|
+
}
|
|
144
211
|
for (const flag of ['shared', 'alert']) {
|
|
145
212
|
if (request?.[flag] == null) continue
|
|
146
213
|
if (typeof request[flag] !== 'boolean') throw new Error(`${flag} 必须为布尔值`)
|
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
|
+
}
|