dsh-hyperliquid 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +3 -0
- package/README.md +23 -9
- package/client.js +4446 -588
- package/docs/research-review-2026-10-16.md +46 -0
- package/index.js +2 -1
- package/lib/chart-drawings.d.ts +27 -0
- package/lib/chart-drawings.js +217 -0
- package/lib/indicators.js +7 -2
- package/lib/moving-average.d.ts +21 -0
- package/lib/moving-average.js +133 -0
- package/lib/remote.js +233 -61
- package/lib/review.d.ts +3 -0
- package/lib/review.js +79 -0
- package/lib/risk-settings.js +20 -0
- package/lib/store.js +124 -19
- package/lib/studies.d.ts +13 -0
- package/lib/studies.js +157 -0
- package/lib/tools.js +128 -22
- package/lib/workspace-settings.d.ts +3 -0
- package/lib/workspace-settings.js +12 -0
- package/package.json +8 -3
- package/skills/hyperliquid-trading/SKILL.md +19 -4
|
@@ -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
|
@@ -94,7 +94,8 @@ export function apply(ctx, config = {}) {
|
|
|
94
94
|
const lines = [
|
|
95
95
|
'## Hyperliquid 交易台(dsh-hyperliquid)',
|
|
96
96
|
`本 Harness 装有 Hyperliquid 交易台(mode=${mode},network=${net})。用户可在面板看行情并填写交易票据;票据只把意图送入当前会话,所有实际下单只能由你调用工具完成。`,
|
|
97
|
-
'
|
|
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=[...],图表会实时计算;不要自己计算均线再用多段趋势线拼折线。工具返回错误时换正确参数重试一次,仍失败就直接告诉用户,不要反复推演。',
|
|
98
99
|
'用户说"当前/界面上这个币"时,先 hyper_context 或 hyper_session status;需要技术判断时用 hyper_market indicators 验证趋势、动量、波动和支撑阻力,可用 hyper_chart 将依据画给用户看。交易前核对行情与账户,说明方向、数量、依据及风险。paper 模式用 hyper_order 执行;live 模式先用 hyper_decide 预检,并用返回的一次性 decisionId 执行 hyper_order。是否可自主执行由宿主配置和钱包策略决定。',
|
|
99
100
|
]
|
|
100
101
|
if (skillRemove) lines.push('复杂的 Hyperliquid 分析、交易或异常对账,请先加载 hyperliquid-trading skill。')
|
|
@@ -0,0 +1,27 @@
|
|
|
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
|
+
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 }
|
|
4
|
+
export declare const DRAWING_POINTS: Record<DrawingKind, number>
|
|
5
|
+
export declare const DRAWING_KINDS: DrawingKind[]
|
|
6
|
+
export declare const DRAWING_CLICKS: Record<DrawingKind, number>
|
|
7
|
+
export declare const LINE_STYLES: LineStyleName[]
|
|
8
|
+
export declare function drawingStyle(request: unknown): DrawingStyle
|
|
9
|
+
export declare const FIB_LEVELS: number[]
|
|
10
|
+
export declare function drawingGeometry(kind: string, request: unknown, now?: number): Record<string, unknown>
|
|
11
|
+
export declare const BRUSH_MIN: number
|
|
12
|
+
export declare const BRUSH_MAX: number
|
|
13
|
+
export declare const TEXT_KINDS: DrawingKind[]
|
|
14
|
+
export declare const FIB_EXT_LEVELS: number[]
|
|
15
|
+
export declare const FIB_TIME_SERIES: number[]
|
|
16
|
+
export declare const GANN_RATIOS: Array<[number, number]>
|
|
17
|
+
export declare const ICON_NAMES: string[]
|
|
18
|
+
export declare const GANN_BOX_LEVELS: number[]
|
|
19
|
+
export declare const STICKERS: Record<'trading' | 'faces' | 'symbols', string[]>
|
|
20
|
+
export declare function isIconLabel(label: unknown): boolean
|
|
21
|
+
export declare const ALERT_KINDS: DrawingKind[]
|
|
22
|
+
export declare function linePriceAt(mark: unknown, time: number): number | null
|
|
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
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-hyperliquid — chart drawing kinds shared by the host (validation, the
|
|
3
|
+
* hyper_chart tool) and the browser chart (tools menu, rendering).
|
|
4
|
+
*
|
|
5
|
+
* level / trend keep their original flat fields (price; fromTime…toPrice);
|
|
6
|
+
* every other kind stores `points: [{ time, price }]` in click order.
|
|
7
|
+
* @module lib/chart-drawings
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** kind → number of anchor points (level: a price only). */
|
|
11
|
+
export const DRAWING_POINTS = {
|
|
12
|
+
level: 1, hray: 1, vline: 1, text: 1, trend: 2, ray: 2, extended: 2, fib: 2, rect: 2, measure: 2, channel: 3,
|
|
13
|
+
// Positions store [entry, stop, target]; the desk creates them with 2 clicks (target defaults to 2R).
|
|
14
|
+
long: 3, short: 3,
|
|
15
|
+
// Fibonacci family, Gann, pitchfork, regression.
|
|
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,
|
|
19
|
+
// Annotation and shapes. brush is freehand: 0 = variable (BRUSH_MIN..BRUSH_MAX points).
|
|
20
|
+
arrow: 2, callout: 2, circle: 2, triangle: 3, brush: 0,
|
|
21
|
+
// More lines and measures: cross line, price label, info line, trend angle, price-only and date-only ranges.
|
|
22
|
+
crossline: 1, pricelabel: 1, infoline: 2, trendangle: 2, pricerange: 2, daterange: 2,
|
|
23
|
+
// Advanced: Elliott waves, harmonic / chart patterns, Gann box & square, pitchfork variants, icons.
|
|
24
|
+
elliott: 6, elliottabc: 4, xabcd: 5, abcd: 4, headshoulders: 7,
|
|
25
|
+
gannbox: 2, gannsquare: 2, schiff: 3, modschiff: 3, icon: 1,
|
|
26
|
+
}
|
|
27
|
+
/** Icon stickers (the drawing's label holds the icon name). */
|
|
28
|
+
export const ICON_NAMES = ['flag', 'star', 'up', 'down', 'warning', 'check', 'cross', 'fire', 'target', 'bell', 'pin', 'clock', 'dollar', 'shield', 'bolt', 'heart', 'question', 'info']
|
|
29
|
+
/** Sticker library (TradingView's emoji stickers): category → emoji. An icon label is a name or `emoji:<char>`. */
|
|
30
|
+
export const STICKERS = {
|
|
31
|
+
trading: ['📈', '📉', '💰', '💸', '🚀', '🎯', '🔥', '💎', '🐂', '🐻', '🐋', '🚨', '🔔', '⏰', '📌', '🧭', '🛑', '🏦', '⚖️', '🪙'],
|
|
32
|
+
faces: ['😀', '😂', '😎', '🤔', '😱', '😭', '😡', '🥳', '🤯', '🙏', '👍', '👎', '👀', '💪', '🫡', '🤝'],
|
|
33
|
+
symbols: ['⭐', '❗', '❓', '💡', '🔒', '🔓', '⛔', '🟢', '🔴', '🟡', '⬆️', '⬇️', '↗️', '↘️', '🏁', '🎉', '✅', '❌', '⚠️', '♻️', '⏸️', '▶️'],
|
|
34
|
+
}
|
|
35
|
+
const STICKER_SET = new Set(Object.values(STICKERS).flat())
|
|
36
|
+
/** Whether an icon drawing's label names a built-in icon or a library sticker. */
|
|
37
|
+
export function isIconLabel(label) {
|
|
38
|
+
if (ICON_NAMES.includes(label)) return true
|
|
39
|
+
if (typeof label !== 'string' || !label.startsWith('emoji:')) return false
|
|
40
|
+
const emoji = label.slice(6)
|
|
41
|
+
return STICKER_SET.has(emoji) || isSingleEmoji(emoji)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Any one emoji (a single grapheme that is pictographic, or a regional-indicator flag), so the whole emoji set works as stickers. */
|
|
45
|
+
export function isSingleEmoji(value) {
|
|
46
|
+
if (typeof value !== 'string' || !value || value.length > 32 || !/\p{Extended_Pictographic}|\p{Regional_Indicator}/u.test(value)) return false
|
|
47
|
+
return [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(value)].length === 1
|
|
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
|
+
export const PROFILE_ROWS_MIN = 6
|
|
56
|
+
export const PROFILE_ROWS_MAX = 200
|
|
57
|
+
export function volumeProfile(bars, { rows = 48, valueArea = 0.7 } = {}) {
|
|
58
|
+
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)
|
|
59
|
+
if (!valid.length) return null
|
|
60
|
+
const low = Math.min(...valid.map((bar) => bar.low)), high = Math.max(...valid.map((bar) => bar.high))
|
|
61
|
+
// A flat range is one row at that exact price, not a row of invented width above it.
|
|
62
|
+
const count = high > low ? rows : 1
|
|
63
|
+
const step = high > low ? (high - low) / count : 0
|
|
64
|
+
const out = Array.from({ length: count }, (_, index) => ({ low: low + index * step, high: count === 1 ? high : low + (index + 1) * step, up: 0, down: 0 }))
|
|
65
|
+
for (const bar of valid) {
|
|
66
|
+
const side = bar.close >= bar.open ? 'up' : 'down'
|
|
67
|
+
if (count === 1) { out[0][side] += bar.volume; continue }
|
|
68
|
+
if (bar.high === bar.low) { out[Math.min(count - 1, Math.floor((bar.low - low) / step))][side] += bar.volume; continue }
|
|
69
|
+
const first = Math.max(0, Math.floor((bar.low - low) / step)), last = Math.min(count - 1, Math.floor((bar.high - low) / step))
|
|
70
|
+
for (let index = first; index <= last; index += 1) {
|
|
71
|
+
const overlap = Math.min(bar.high, out[index].high) - Math.max(bar.low, out[index].low)
|
|
72
|
+
if (overlap > 0) out[index][side] += bar.volume * overlap / (bar.high - bar.low)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const total = out.reduce((sum, row) => sum + row.up + row.down, 0)
|
|
76
|
+
if (!(total > 0)) return null
|
|
77
|
+
const size = (index) => index >= 0 && index < count ? out[index].up + out[index].down : -1
|
|
78
|
+
let poc = 0
|
|
79
|
+
for (let index = 1; index < count; index += 1) if (size(index) > size(poc)) poc = index
|
|
80
|
+
let lo = poc, hi = poc, held = size(poc)
|
|
81
|
+
while (held < total * valueArea && (lo > 0 || hi < count - 1)) {
|
|
82
|
+
if (size(hi + 1) >= size(lo - 1)) { hi += 1; held += size(hi) } else { lo -= 1; held += size(lo) }
|
|
83
|
+
}
|
|
84
|
+
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 }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Gann box divisions on both axes (TradingView defaults). */
|
|
88
|
+
export const GANN_BOX_LEVELS = [0, 0.25, 0.382, 0.5, 0.618, 0.75, 1]
|
|
89
|
+
/** Drawings a price can cross: alerts fire when the mid price crosses them (once, like TradingView). */
|
|
90
|
+
export const ALERT_KINDS = ['level', 'hray', 'trend', 'ray', 'extended']
|
|
91
|
+
|
|
92
|
+
/** The line's price at `time`, or null when the drawing does not exist at that time. */
|
|
93
|
+
export function linePriceAt(mark, time) {
|
|
94
|
+
if (mark.kind === 'level') return Number(mark.price)
|
|
95
|
+
const [a, b] = mark.kind === 'trend'
|
|
96
|
+
? [{ time: mark.fromTime, price: mark.fromPrice }, { time: mark.toTime, price: mark.toPrice }]
|
|
97
|
+
: mark.points ?? []
|
|
98
|
+
if (!a) return null
|
|
99
|
+
if (mark.kind === 'hray') return time >= a.time ? a.price : null
|
|
100
|
+
if (!b || b.time === a.time) return null
|
|
101
|
+
if (mark.kind === 'trend' && (time < Math.min(a.time, b.time) || time > Math.max(a.time, b.time))) return null
|
|
102
|
+
if (mark.kind === 'ray' && (time - a.time) * (b.time - a.time) < 0) return null
|
|
103
|
+
return a.price + (b.price - a.price) * (time - a.time) / (b.time - a.time)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const BRUSH_MIN = 2
|
|
107
|
+
export const BRUSH_MAX = 500
|
|
108
|
+
/** Kinds whose label is free text shown on the chart (up to 200 characters). */
|
|
109
|
+
export const TEXT_KINDS = ['text', 'callout']
|
|
110
|
+
/** Clicks that create a drawing when they differ from its stored anchor count. */
|
|
111
|
+
export const DRAWING_CLICKS = { ...Object.fromEntries(Object.entries(DRAWING_POINTS)), long: 2, short: 2 }
|
|
112
|
+
export const DRAWING_KINDS = Object.keys(DRAWING_POINTS)
|
|
113
|
+
/** Fibonacci retracement levels (0 = second point, 1 = first point, as in TradingView). */
|
|
114
|
+
export const FIB_LEVELS = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1]
|
|
115
|
+
/** Trend-based Fibonacci extension levels (projected from the third point, TradingView defaults). */
|
|
116
|
+
export const FIB_EXT_LEVELS = [0, 0.382, 0.618, 1, 1.272, 1.618, 2, 2.618]
|
|
117
|
+
/** Fibonacci time zones: bar multiples of the first two points' spacing. */
|
|
118
|
+
export const FIB_TIME_SERIES = [0, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89]
|
|
119
|
+
/** Gann fan angles as price/time ratios relative to the 1×1 line. */
|
|
120
|
+
export const GANN_RATIOS = [[1, 8], [1, 4], [1, 3], [1, 2], [1, 1], [2, 1], [3, 1], [4, 1], [8, 1]]
|
|
121
|
+
|
|
122
|
+
const OLDEST = Date.UTC(2015, 0, 1)
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Validate the geometry of one drawing request (throws on invalid input).
|
|
126
|
+
* @returns the geometry fields to persist for that kind.
|
|
127
|
+
*/
|
|
128
|
+
export function drawingGeometry(kind, request, now = Date.now()) {
|
|
129
|
+
if (!DRAWING_KINDS.includes(kind)) throw new Error(`画线类型必须为 ${DRAWING_KINDS.join('/')}`)
|
|
130
|
+
const latest = now + 365 * 86_400_000
|
|
131
|
+
const point = (time, price, label) => {
|
|
132
|
+
const t = Number(time), p = Number(price)
|
|
133
|
+
if (!Number.isFinite(t) || t < OLDEST || t > latest) throw new Error(`${label}时间必须为 2015 年至今后一年内的毫秒时间戳`)
|
|
134
|
+
if (!Number.isFinite(p) || p <= 0) throw new Error(`${label}价格必须为正数`)
|
|
135
|
+
return { time: t, price: p }
|
|
136
|
+
}
|
|
137
|
+
if (kind === 'level') {
|
|
138
|
+
const price = Number(request?.price)
|
|
139
|
+
if (!Number.isFinite(price) || price <= 0) throw new Error('水平线 price 必须为正数')
|
|
140
|
+
return { price }
|
|
141
|
+
}
|
|
142
|
+
if (kind === 'trend') {
|
|
143
|
+
const from = point(request?.fromTime, request?.fromPrice, '趋势线起点')
|
|
144
|
+
const to = point(request?.toTime, request?.toPrice, '趋势线终点')
|
|
145
|
+
if (to.time <= from.time) throw new Error('趋势线终点时间必须晚于起点')
|
|
146
|
+
return { fromTime: from.time, fromPrice: from.price, toTime: to.time, toPrice: to.price }
|
|
147
|
+
}
|
|
148
|
+
const need = DRAWING_POINTS[kind]
|
|
149
|
+
const raw = request?.points
|
|
150
|
+
if (kind === 'brush') {
|
|
151
|
+
if (!Array.isArray(raw) || raw.length < BRUSH_MIN || raw.length > BRUSH_MAX) throw new Error(`brush 需要 ${BRUSH_MIN}-${BRUSH_MAX} 个 points`)
|
|
152
|
+
} else if (!Array.isArray(raw) || raw.length !== need) throw new Error(`${kind} 需要 ${need} 个 points({ time, price })`)
|
|
153
|
+
const points = raw.map((item, index) => point(item?.time, item?.price, `第 ${index + 1} 个点`))
|
|
154
|
+
if (['trend', 'ray', 'extended', 'channel', 'fibchannel', 'gannfan', 'regression', 'fibtime', 'infoline', 'trendangle'].includes(kind) && points[0].time === points[1].time) throw new Error('前两个点的时间不能相同')
|
|
155
|
+
if (kind === 'vprofile') {
|
|
156
|
+
if (points[0].time === points[1].time) throw new Error('成交量分布需要一个时间区间')
|
|
157
|
+
// Only the time range matters, so either drawing direction is fine.
|
|
158
|
+
if (points[1].time < points[0].time) points.reverse()
|
|
159
|
+
}
|
|
160
|
+
if ((kind === 'regression' || kind === 'fibtime' || kind === 'gannfan') && points[1].time < points[0].time) throw new Error('第二个点必须晚于第一个点')
|
|
161
|
+
if (kind === 'long' || kind === 'short') {
|
|
162
|
+
const [entry, stop, target] = points.map((item) => item.price)
|
|
163
|
+
const ok = kind === 'long' ? stop < entry && entry < target : target < entry && entry < stop
|
|
164
|
+
if (!ok) throw new Error(kind === 'long' ? '多头头寸需要 止损 < 入场 < 止盈' : '空头头寸需要 止盈 < 入场 < 止损')
|
|
165
|
+
if (points[1].time <= points[0].time) throw new Error('头寸右边界时间必须晚于入场时间')
|
|
166
|
+
}
|
|
167
|
+
return { points }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const COLOR = /^#[0-9a-fA-F]{6}$/
|
|
171
|
+
export const LINE_STYLES = ['solid', 'dashed', 'dotted']
|
|
172
|
+
|
|
173
|
+
/** Validate optional appearance fields; absent keys are left untouched. */
|
|
174
|
+
export function drawingStyle(request) {
|
|
175
|
+
const style = {}
|
|
176
|
+
if (request?.color != null) {
|
|
177
|
+
if (typeof request.color !== 'string' || !COLOR.test(request.color)) throw new Error('画线颜色必须为 #RRGGBB')
|
|
178
|
+
style.color = request.color.toLowerCase()
|
|
179
|
+
}
|
|
180
|
+
if (request?.lineWidth != null) {
|
|
181
|
+
const width = Number(request.lineWidth)
|
|
182
|
+
if (!Number.isInteger(width) || width < 1 || width > 4) throw new Error('线宽必须为 1-4 的整数')
|
|
183
|
+
style.lineWidth = width
|
|
184
|
+
}
|
|
185
|
+
if (request?.lineStyle != null) {
|
|
186
|
+
if (!LINE_STYLES.includes(request.lineStyle)) throw new Error(`线型必须为 ${LINE_STYLES.join('/')}`)
|
|
187
|
+
style.lineStyle = request.lineStyle
|
|
188
|
+
}
|
|
189
|
+
// Volume profile settings (TradingView "Rows" and "Value Area Volume").
|
|
190
|
+
if (request?.rows != null) {
|
|
191
|
+
if (request?.kind != null && request.kind !== 'vprofile') throw new Error('rows 只适用于固定范围成交量分布')
|
|
192
|
+
const rows = Number(request.rows)
|
|
193
|
+
if (!Number.isInteger(rows) || rows < PROFILE_ROWS_MIN || rows > PROFILE_ROWS_MAX) throw new Error(`成交量分布行数必须为 ${PROFILE_ROWS_MIN}-${PROFILE_ROWS_MAX} 的整数`)
|
|
194
|
+
style.rows = rows
|
|
195
|
+
}
|
|
196
|
+
if (request?.valueArea != null) {
|
|
197
|
+
if (request?.kind != null && request.kind !== 'vprofile') throw new Error('valueArea 只适用于固定范围成交量分布')
|
|
198
|
+
const share = Number(request.valueArea)
|
|
199
|
+
if (!Number.isFinite(share) || share < 0.5 || share > 0.95) throw new Error('价值区比例必须在 0.5-0.95 之间')
|
|
200
|
+
style.valueArea = share
|
|
201
|
+
}
|
|
202
|
+
for (const flag of ['shared', 'alert']) {
|
|
203
|
+
if (request?.[flag] == null) continue
|
|
204
|
+
if (typeof request[flag] !== 'boolean') throw new Error(`${flag} 必须为布尔值`)
|
|
205
|
+
style[flag] = request[flag]
|
|
206
|
+
}
|
|
207
|
+
if (style.alert && request?.kind != null && !ALERT_KINDS.includes(request.kind)) throw new Error(`只有 ${ALERT_KINDS.join('/')} 支持价格提醒`)
|
|
208
|
+
if (request?.hidden != null) {
|
|
209
|
+
if (typeof request.hidden !== 'boolean') throw new Error('hidden 必须为布尔值')
|
|
210
|
+
style.hidden = request.hidden
|
|
211
|
+
}
|
|
212
|
+
if (request?.locked != null) {
|
|
213
|
+
if (typeof request.locked !== 'boolean') throw new Error('locked 必须为布尔值')
|
|
214
|
+
style.locked = request.locked
|
|
215
|
+
}
|
|
216
|
+
return style
|
|
217
|
+
}
|
package/lib/indicators.js
CHANGED
|
@@ -46,13 +46,18 @@ function closeTime(row, interval) {
|
|
|
46
46
|
return minutes ? row.t + minutes * 60_000 - 1 : row.t
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
/** Valid, de-duplicated, ascending candles split into closed rows and the count of still-forming ones. */
|
|
50
|
+
export function closedCandles(candles, { now = Date.now(), interval } = {}) {
|
|
50
51
|
const valid = (candles ?? []).map((c) => ({ t: Number(c.t), T: c.T, o: Number(c.o), h: Number(c.h), l: Number(c.l), c: Number(c.c), v: Number(c.v) }))
|
|
51
52
|
.filter((c) => [c.t, c.o, c.h, c.l, c.c, c.v].every(Number.isFinite) && c.t > 0 && c.h >= c.l && c.c > 0)
|
|
52
53
|
.sort((a, b) => a.t - b.t)
|
|
53
54
|
const unique = [...new Map(valid.map((row) => [row.t, row])).values()]
|
|
54
55
|
const rows = unique.filter((row) => closeTime(row, interval) < now)
|
|
55
|
-
|
|
56
|
+
return { rows, excludedOpen: unique.length - rows.length, closeTime: (row) => closeTime(row, interval) }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function computeIndicators(candles, { now = Date.now(), interval } = {}) {
|
|
60
|
+
const { rows, excludedOpen } = closedCandles(candles, { now, interval })
|
|
56
61
|
if (rows.length < 50) return { ready: false, candlesUsed: rows.length, excludedOpen, reason: '至少需要 50 根已收盘的有效 K 线' }
|
|
57
62
|
const close = rows.map((c) => c.c)
|
|
58
63
|
const volume = rows.map((c) => c.v)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type MaKind = 'sma' | 'ema' | 'smma' | 'wma'
|
|
2
|
+
export type MaSource = 'close' | 'open' | 'high' | 'low' | 'hl2' | 'hlc3' | 'ohlc4'
|
|
3
|
+
export interface MaLine {
|
|
4
|
+
id: string
|
|
5
|
+
kind: MaKind
|
|
6
|
+
period: number
|
|
7
|
+
source: MaSource
|
|
8
|
+
smoothing: { kind: MaKind; period: number } | null
|
|
9
|
+
color?: string
|
|
10
|
+
}
|
|
11
|
+
export interface MaCandle { o: number; h: number; l: number; c: number }
|
|
12
|
+
export declare const MA_KINDS: MaKind[]
|
|
13
|
+
export declare const MA_SOURCES: MaSource[]
|
|
14
|
+
export declare const MA_MAX_LINES: number
|
|
15
|
+
export declare const MA_MAX_PERIOD: number
|
|
16
|
+
export declare function normalizeMaLine(input: unknown): MaLine
|
|
17
|
+
export declare function normalizeMaLines(list: unknown): MaLine[]
|
|
18
|
+
export declare function maLabel(line: MaLine): string
|
|
19
|
+
export declare function movingAverage(values: Array<number | null>, kind: MaKind, period: number): Array<number | null>
|
|
20
|
+
export declare function maSeries(candles: MaCandle[], line: MaLine): { values: Array<number | null>; smoothed: Array<number | null> | null }
|
|
21
|
+
export declare function maLatest(candles: MaCandle[], lines: MaLine[]): Array<{ id: string; label: string; value: number | null; smoothed: number | null; line: MaLine }>
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-hyperliquid — configurable moving-average lines, shared by the host
|
|
3
|
+
* (hyper_market / hyper_chart) and the browser chart so both compute the
|
|
4
|
+
* exact same values.
|
|
5
|
+
*
|
|
6
|
+
* Definitions follow TradingView Pine:
|
|
7
|
+
* SMA ta.sma arithmetic mean of the last n values
|
|
8
|
+
* EMA ta.ema alpha = 2/(n+1), seeded with the SMA of the first n values
|
|
9
|
+
* SMMA ta.rma alpha = 1/n (Wilder / smoothed MA), seeded with the SMA
|
|
10
|
+
* WMA ta.wma linear weights n, n-1, …, 1 (newest heaviest)
|
|
11
|
+
* An optional smoothing applies a second MA to the first line, like the
|
|
12
|
+
* "Smoothing" setting of TradingView's moving-average indicators.
|
|
13
|
+
* @module lib/moving-average
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const MA_KINDS = ['sma', 'ema', 'smma', 'wma']
|
|
17
|
+
export const MA_SOURCES = ['close', 'open', 'high', 'low', 'hl2', 'hlc3', 'ohlc4']
|
|
18
|
+
export const MA_MAX_LINES = 8
|
|
19
|
+
export const MA_MAX_PERIOD = 500
|
|
20
|
+
const COLOR = /^#[0-9a-fA-F]{6}$/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Validate and canonicalize one line definition (throws on invalid input).
|
|
24
|
+
* @param {object} input - { kind, period, source?, smoothing?: { kind, period }, color? }
|
|
25
|
+
* @returns {{ id: string, kind: string, period: number, source: string, smoothing: { kind: string, period: number } | null, color?: string }}
|
|
26
|
+
*/
|
|
27
|
+
export function normalizeMaLine(input) {
|
|
28
|
+
if (!input || typeof input !== 'object') throw new Error('均线定义无效')
|
|
29
|
+
const kind = String(input.kind ?? '').toLowerCase()
|
|
30
|
+
if (!MA_KINDS.includes(kind)) throw new Error(`均线类型必须为 ${MA_KINDS.join('/')}`)
|
|
31
|
+
const period = Number(input.period)
|
|
32
|
+
if (!Number.isInteger(period) || period < 1 || period > MA_MAX_PERIOD) throw new Error(`均线周期必须为 1-${MA_MAX_PERIOD} 的整数`)
|
|
33
|
+
const source = String(input.source ?? 'close').toLowerCase()
|
|
34
|
+
if (!MA_SOURCES.includes(source)) throw new Error(`均线价格源必须为 ${MA_SOURCES.join('/')}`)
|
|
35
|
+
let smoothing = null
|
|
36
|
+
if (input.smoothing != null) {
|
|
37
|
+
const sKind = String(input.smoothing.kind ?? '').toLowerCase()
|
|
38
|
+
const sPeriod = Number(input.smoothing.period)
|
|
39
|
+
if (!MA_KINDS.includes(sKind)) throw new Error(`平滑类型必须为 ${MA_KINDS.join('/')}`)
|
|
40
|
+
if (!Number.isInteger(sPeriod) || sPeriod < 1 || sPeriod > MA_MAX_PERIOD) throw new Error(`平滑周期必须为 1-${MA_MAX_PERIOD} 的整数`)
|
|
41
|
+
smoothing = { kind: sKind, period: sPeriod }
|
|
42
|
+
}
|
|
43
|
+
const id = `${kind}${period}${source === 'close' ? '' : '-' + source}${smoothing ? `-${smoothing.kind}${smoothing.period}` : ''}`
|
|
44
|
+
const line = { id, kind, period, source, smoothing }
|
|
45
|
+
if (input.color != null) {
|
|
46
|
+
if (typeof input.color !== 'string' || !COLOR.test(input.color)) throw new Error('均线颜色必须为 #RRGGBB')
|
|
47
|
+
line.color = input.color.toLowerCase()
|
|
48
|
+
}
|
|
49
|
+
return line
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Validate a whole set: at most MA_MAX_LINES, de-duplicated by id (last definition wins its slot). */
|
|
53
|
+
export function normalizeMaLines(list) {
|
|
54
|
+
if (!Array.isArray(list)) throw new Error('均线列表无效')
|
|
55
|
+
const byId = new Map()
|
|
56
|
+
for (const item of list) { const line = normalizeMaLine(item); byId.set(line.id, line) }
|
|
57
|
+
if (byId.size > MA_MAX_LINES) throw new Error(`最多同时显示 ${MA_MAX_LINES} 条均线`)
|
|
58
|
+
return [...byId.values()]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** "SMMA 7", "EMA 21 hl2", "SMA 20 · EMA 9". */
|
|
62
|
+
export function maLabel(line) {
|
|
63
|
+
return `${line.kind.toUpperCase()} ${line.period}${line.source === 'close' ? '' : ' ' + line.source}${line.smoothing ? ` · ${line.smoothing.kind.toUpperCase()} ${line.smoothing.period}` : ''}`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function sourceOf(candle, source) {
|
|
67
|
+
const o = Number(candle.o), h = Number(candle.h), l = Number(candle.l), c = Number(candle.c)
|
|
68
|
+
switch (source) {
|
|
69
|
+
case 'open': return o
|
|
70
|
+
case 'high': return h
|
|
71
|
+
case 'low': return l
|
|
72
|
+
case 'hl2': return (h + l) / 2
|
|
73
|
+
case 'hlc3': return (h + l + c) / 3
|
|
74
|
+
case 'ohlc4': return (o + h + l + c) / 4
|
|
75
|
+
default: return c
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* One MA over a series that may start with nulls (e.g. the input of a smoothing pass).
|
|
81
|
+
* @param {Array<number|null>} values
|
|
82
|
+
* @param {string} kind
|
|
83
|
+
* @param {number} period
|
|
84
|
+
* @returns {Array<number|null>} aligned with `values`; null until enough data.
|
|
85
|
+
*/
|
|
86
|
+
export function movingAverage(values, kind, period) {
|
|
87
|
+
const out = new Array(values.length).fill(null)
|
|
88
|
+
const window = []
|
|
89
|
+
let prior = null
|
|
90
|
+
for (let i = 0; i < values.length; i += 1) {
|
|
91
|
+
const value = values[i]
|
|
92
|
+
if (value == null || !Number.isFinite(value)) { if (kind === 'sma' || kind === 'wma') window.length = 0; continue }
|
|
93
|
+
if (kind === 'sma' || kind === 'wma') {
|
|
94
|
+
window.push(value)
|
|
95
|
+
if (window.length > period) window.shift()
|
|
96
|
+
if (window.length < period) continue
|
|
97
|
+
if (kind === 'sma') out[i] = window.reduce((sum, v) => sum + v, 0) / period
|
|
98
|
+
else { let num = 0; for (let k = 0; k < period; k += 1) num += window[k] * (k + 1); out[i] = num / (period * (period + 1) / 2) }
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
// ema / smma: seed with the SMA of the first `period` values, then recurse.
|
|
102
|
+
if (prior == null) {
|
|
103
|
+
window.push(value)
|
|
104
|
+
if (window.length < period) continue
|
|
105
|
+
prior = window.reduce((sum, v) => sum + v, 0) / period
|
|
106
|
+
} else {
|
|
107
|
+
const alpha = kind === 'ema' ? 2 / (period + 1) : 1 / period
|
|
108
|
+
prior = alpha * value + (1 - alpha) * prior
|
|
109
|
+
}
|
|
110
|
+
out[i] = prior
|
|
111
|
+
}
|
|
112
|
+
return out
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Values of one normalized line over candles (ascending by time).
|
|
117
|
+
* @returns {{ values: Array<number|null>, smoothed: Array<number|null> | null }}
|
|
118
|
+
*/
|
|
119
|
+
export function maSeries(candles, line) {
|
|
120
|
+
const input = candles.map((candle) => { const v = sourceOf(candle, line.source); return Number.isFinite(v) ? v : null })
|
|
121
|
+
const values = movingAverage(input, line.kind, line.period)
|
|
122
|
+
const smoothed = line.smoothing ? movingAverage(values, line.smoothing.kind, line.smoothing.period) : null
|
|
123
|
+
return { values, smoothed }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Latest defined value of each line (and its smoothing) — what the agent reads. */
|
|
127
|
+
export function maLatest(candles, lines) {
|
|
128
|
+
return lines.map((line) => {
|
|
129
|
+
const { values, smoothed } = maSeries(candles, line)
|
|
130
|
+
const last = (series) => { for (let i = series.length - 1; i >= 0; i -= 1) if (series[i] != null) return series[i]; return null }
|
|
131
|
+
return { id: line.id, label: maLabel(line), value: last(values), smoothed: smoothed ? last(smoothed) : null, line }
|
|
132
|
+
})
|
|
133
|
+
}
|