dsh-hyperliquid 0.2.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/README.md +14 -8
- package/client.js +1969 -543
- package/docs/research-review-2026-10-16.md +46 -0
- package/index.js +2 -1
- package/lib/chart-drawings.d.ts +6 -2
- package/lib/chart-drawings.js +58 -0
- package/lib/remote.js +105 -34
- package/lib/review.d.ts +3 -0
- package/lib/review.js +79 -0
- package/lib/store.js +38 -8
- package/lib/studies.d.ts +13 -0
- package/lib/studies.js +157 -0
- package/lib/tools.js +64 -19
- package/lib/workspace-settings.d.ts +3 -0
- package/lib/workspace-settings.js +12 -0
- package/package.json +7 -4
- package/skills/hyperliquid-trading/SKILL.md +8 -3
|
@@ -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。')
|
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 { 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,7 @@ 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
|
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,44 @@ 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
|
+
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
|
+
|
|
47
87
|
/** Gann box divisions on both axes (TradingView defaults). */
|
|
48
88
|
export const GANN_BOX_LEVELS = [0, 0.25, 0.382, 0.5, 0.618, 0.75, 1]
|
|
49
89
|
/** Drawings a price can cross: alerts fire when the mid price crosses them (once, like TradingView). */
|
|
@@ -112,6 +152,11 @@ export function drawingGeometry(kind, request, now = Date.now()) {
|
|
|
112
152
|
} else if (!Array.isArray(raw) || raw.length !== need) throw new Error(`${kind} 需要 ${need} 个 points({ time, price })`)
|
|
113
153
|
const points = raw.map((item, index) => point(item?.time, item?.price, `第 ${index + 1} 个点`))
|
|
114
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
|
+
}
|
|
115
160
|
if ((kind === 'regression' || kind === 'fibtime' || kind === 'gannfan') && points[1].time < points[0].time) throw new Error('第二个点必须晚于第一个点')
|
|
116
161
|
if (kind === 'long' || kind === 'short') {
|
|
117
162
|
const [entry, stop, target] = points.map((item) => item.price)
|
|
@@ -141,6 +186,19 @@ export function drawingStyle(request) {
|
|
|
141
186
|
if (!LINE_STYLES.includes(request.lineStyle)) throw new Error(`线型必须为 ${LINE_STYLES.join('/')}`)
|
|
142
187
|
style.lineStyle = request.lineStyle
|
|
143
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
|
+
}
|
|
144
202
|
for (const flag of ['shared', 'alert']) {
|
|
145
203
|
if (request?.[flag] == null) continue
|
|
146
204
|
if (typeof request[flag] !== 'boolean') throw new Error(`${flag} 必须为布尔值`)
|
package/lib/remote.js
CHANGED
|
@@ -16,6 +16,9 @@ import { TradingGuard } from './trading-guard.js'
|
|
|
16
16
|
import { normalizeMaLines } from './moving-average.js'
|
|
17
17
|
import { ALERT_KINDS, ICON_NAMES, TEXT_KINDS, drawingGeometry, drawingStyle, isIconLabel, linePriceAt } from './chart-drawings.js'
|
|
18
18
|
import { normalizeRiskSettings } from './risk-settings.js'
|
|
19
|
+
import { INDICATOR_IDS, normalizeIndicatorSettings } from './studies.js'
|
|
20
|
+
import { normalizeResearch } from './workspace-settings.js'
|
|
21
|
+
import { fundingWindow } from './review.js'
|
|
19
22
|
|
|
20
23
|
const TOOL_CALL = Symbol('verified DSH tool call')
|
|
21
24
|
|
|
@@ -482,6 +485,20 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
482
485
|
return maLines
|
|
483
486
|
}
|
|
484
487
|
|
|
488
|
+
/** Agent sets what the session chart shows (interval, indicator panes); the desk follows on `chart.view`. */
|
|
489
|
+
setSessionChartView(sessionId, { interval, show = [], hide = [] } = {}) {
|
|
490
|
+
this.requireTradingSession(sessionId)
|
|
491
|
+
const unknown = [...show, ...hide].filter((id) => !INDICATOR_IDS.includes(id))
|
|
492
|
+
if (unknown.length) throw new Error(`未知指标 ${unknown.join(',')},可选:${INDICATOR_IDS.join(', ')}`)
|
|
493
|
+
const current = this.store.getSessionWorkspace(sessionId)
|
|
494
|
+
const patch = { indicators: [...new Set([...(current.indicators ?? []).filter((id) => !hide.includes(id)), ...show])] }
|
|
495
|
+
if (interval != null) patch.interval = requireInterval(interval)
|
|
496
|
+
const next = this.store.updateSessionWorkspace(sessionId, patch)
|
|
497
|
+
if (this.desk?.sessionId === sessionId && patch.interval) this.desk.interval = patch.interval
|
|
498
|
+
this.store.recordEvent('chart.view', { sessionId, interval: next.interval, indicators: next.indicators })
|
|
499
|
+
return next
|
|
500
|
+
}
|
|
501
|
+
|
|
485
502
|
/** Move, reshape, restyle or relabel one drawing; geometry is re-validated as a whole. */
|
|
486
503
|
editChartMark(id, patch, sessionId) {
|
|
487
504
|
this.requireTradingSession(sessionId)
|
|
@@ -492,7 +509,7 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
492
509
|
const fields = { ...drawingStyle({ ...patch, kind: current.kind }) }
|
|
493
510
|
if (patch?.alert === true) fields.alertTriggeredAt = undefined
|
|
494
511
|
// null clears a style back to its default (undo of a first restyle relies on this).
|
|
495
|
-
for (const key of ['color', 'lineWidth', 'lineStyle']) if (key in patch && patch[key] === null) fields[key] = undefined
|
|
512
|
+
for (const key of ['color', 'lineWidth', 'lineStyle', 'rows', 'valueArea']) if (key in patch && patch[key] === null) fields[key] = undefined
|
|
496
513
|
if (geometryKeys.some((key) => key in patch)) Object.assign(fields, drawingGeometry(current.kind, merged))
|
|
497
514
|
if ('label' in patch) {
|
|
498
515
|
const label = String(patch.label ?? '').trim()
|
|
@@ -573,15 +590,22 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
573
590
|
patch.tapeTab = input.tapeTab
|
|
574
591
|
}
|
|
575
592
|
if ('bottomTab' in input) {
|
|
576
|
-
if (!['positions', 'orders', 'account'].includes(input.bottomTab)) throw new Error('账户页签无效')
|
|
593
|
+
if (!['positions', 'orders', 'account', 'review'].includes(input.bottomTab)) throw new Error('账户页签无效')
|
|
577
594
|
patch.bottomTab = input.bottomTab
|
|
578
595
|
}
|
|
579
596
|
if ('indicators' in input) {
|
|
580
|
-
const allowed =
|
|
597
|
+
const allowed = INDICATOR_IDS
|
|
581
598
|
if (!Array.isArray(input.indicators) || input.indicators.length > allowed.length || input.indicators.some((id) => !allowed.includes(id))) throw new Error('图表指标无效')
|
|
582
599
|
patch.indicators = [...new Set(input.indicators)]
|
|
583
600
|
}
|
|
584
601
|
if ('maLines' in input) patch.maLines = normalizeMaLines(input.maLines)
|
|
602
|
+
if ('indicatorSettings' in input) patch.indicatorSettings = normalizeIndicatorSettings(input.indicatorSettings)
|
|
603
|
+
if ('research' in input) patch.research = normalizeResearch(input.research)
|
|
604
|
+
if ('showDataWindow' in input) { if (typeof input.showDataWindow !== 'boolean') throw Error('数据窗口设置无效'); patch.showDataWindow = input.showDataWindow }
|
|
605
|
+
if ('chartType' in input) {
|
|
606
|
+
if (!['candles', 'bars', 'line', 'area', 'heikinAshi'].includes(input.chartType)) throw new Error('图表类型无效')
|
|
607
|
+
patch.chartType = input.chartType
|
|
608
|
+
}
|
|
585
609
|
if ('priceScale' in input) {
|
|
586
610
|
if (!['normal', 'log', 'percentage'].includes(input.priceScale)) throw new Error('价格刻度无效')
|
|
587
611
|
patch.priceScale = input.priceScale
|
|
@@ -625,9 +649,30 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
625
649
|
return result
|
|
626
650
|
}
|
|
627
651
|
|
|
652
|
+
async indicatorTemplates(request) {
|
|
653
|
+
const action = request?.action ?? 'list'
|
|
654
|
+
if (action === 'list') return { templates: this.store.indicatorTemplates() }
|
|
655
|
+
if (action === 'remove') { this.store.removeIndicatorTemplate(String(request?.id ?? '')); return { templates: this.store.indicatorTemplates() } }
|
|
656
|
+
if (action !== 'save') throw Error('指标模板操作无效')
|
|
657
|
+
const name = String(request?.name ?? '').trim()
|
|
658
|
+
if (!name || name.length > 60) throw Error('模板名称需要 1–60 个字符')
|
|
659
|
+
const indicators = request?.indicators
|
|
660
|
+
if (!Array.isArray(indicators) || indicators.length > INDICATOR_IDS.length || indicators.some((id) => !INDICATOR_IDS.includes(id))) throw Error('模板指标无效')
|
|
661
|
+
const id = request?.id
|
|
662
|
+
if (id != null && !this.store.indicatorTemplates().some((row) => row.id === id)) throw Error('指标模板不存在')
|
|
663
|
+
const template = this.store.saveIndicatorTemplate({ id, name, indicators: [...new Set(indicators)], indicatorSettings: normalizeIndicatorSettings(request?.indicatorSettings), maLines: normalizeMaLines(request?.maLines ?? []) })
|
|
664
|
+
return { template, templates: this.store.indicatorTemplates() }
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async updatePaperCosts(request) {
|
|
668
|
+
if (this.activeTradeOps > 0) throw Error('交易操作仍在进行,请稍后修改模拟成本')
|
|
669
|
+
return { paperCosts: this.store.setPaperCosts(request?.costs) }
|
|
670
|
+
}
|
|
671
|
+
|
|
628
672
|
async drawChartMark(request) {
|
|
629
673
|
const sessionId = String(request?.sessionId ?? '')
|
|
630
|
-
|
|
674
|
+
// Undo re-creates a deleted agent drawing; keep its origin so the chart still labels it as the agent's.
|
|
675
|
+
return this.addChartMark(request, sessionId, request?.restoreSource === 'agent' ? 'agent' : 'user')
|
|
631
676
|
}
|
|
632
677
|
|
|
633
678
|
async eraseChartMark(request) {
|
|
@@ -722,6 +767,7 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
722
767
|
const address = await this.resolveAddress()
|
|
723
768
|
const wallet = this.cfg.signer === 'web3' ? this.walletService() : null
|
|
724
769
|
return {
|
|
770
|
+
paperCosts: this.store.paperCosts?.() ?? { feeBps: 4.5, slippageBps: 5 },
|
|
725
771
|
network: this.cfg.network,
|
|
726
772
|
tradingMode: this.cfg.tradingMode,
|
|
727
773
|
signer: this.cfg.signer,
|
|
@@ -832,7 +878,7 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
832
878
|
marginUsed: risk.marksConfirmed ? risk.marginUsed : null,
|
|
833
879
|
availableMargin: risk.marksConfirmed ? Math.max(0, risk.equity - risk.marginUsed) : null,
|
|
834
880
|
marksConfirmed: risk.marksConfirmed, positions: risk.positions, fills: paper.fills.slice(0, 50),
|
|
835
|
-
simulation: '
|
|
881
|
+
simulation: '已模拟配置的滑点与手续费;未模拟盘口深度、资金费或强平' }
|
|
836
882
|
}
|
|
837
883
|
const address = await this.resolveAddress()
|
|
838
884
|
if (!address) throw new Error('live 模式需要钱包(config.privateKey 或 signer=web3)')
|
|
@@ -926,25 +972,45 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
926
972
|
|
|
927
973
|
/** Explicitly loaded history: these weighted API calls must not join the 3s account poll. */
|
|
928
974
|
async tradeHistory(request) {
|
|
929
|
-
|
|
975
|
+
const client = this.client, mode = this.cfg.tradingMode, network = this.cfg.network
|
|
976
|
+
if (request?.days != null && ![7, 30, 90].includes(request.days)) throw Error('复盘支持最近 7/30/90 天')
|
|
977
|
+
const end = Date.now(), requestedStart = request?.days != null ? end - request.days * 86_400_000 : request?.startTime == null ? null : Number(request.startTime)
|
|
978
|
+
if (requestedStart != null && (!Number.isFinite(requestedStart) || requestedStart < end - 90 * 86_400_000 || requestedStart >= end)) throw Error('复盘时间范围必须在最近 90 天内')
|
|
979
|
+
const start = requestedStart ?? Math.max(0, end - 30 * 86_400_000)
|
|
980
|
+
let result
|
|
981
|
+
if (mode === 'paper') {
|
|
930
982
|
const paper = this.store.paperAccount()
|
|
931
|
-
|
|
983
|
+
result = { mode: 'paper', orders: paper.orders.slice(0, 5000), fills: paper.fills.slice(0, 5000), paperCosts: this.store.paperCosts(), funding: [],
|
|
984
|
+
coverage: { fundingStatus: 'not-simulated', fillsLimited: paper.fills.length >= 200, ordersLimited: paper.orders.length >= 200 } }
|
|
985
|
+
} else {
|
|
986
|
+
const address = await this.resolveAddress()
|
|
987
|
+
if (!address) throw Error('未配置交易主账户地址')
|
|
988
|
+
const [orders, fills] = await Promise.all([client.historicalOrders(address), client.userFills(address)])
|
|
989
|
+
if (!Array.isArray(orders) || !Array.isArray(fills)) throw Error('交易所历史数据响应无效')
|
|
990
|
+
const decisions = this.store.liveDecisions ?? {}
|
|
991
|
+
const rows = orders.slice(0, 2000).map((entry) => {
|
|
992
|
+
const order = entry.order ?? entry, local = this.store.liveOrders?.[order.cloid], decision = decisions[local?.decisionId]
|
|
993
|
+
return { ...order, status: entry.status ?? local?.status ?? 'unknown', statusTimestamp: entry.statusTimestamp,
|
|
994
|
+
referencePx: decision?.preview?.mid ?? null,
|
|
995
|
+
reason: local?.thesis ?? decision?.thesis ?? null, sessionId: local?.sessionId ?? decision?.sessionId ?? null }
|
|
996
|
+
})
|
|
997
|
+
result = { mode: 'live', orders: rows, fills: fills.slice(0, 2000), funding: [],
|
|
998
|
+
coverage: { fundingStatus: 'not-requested', fillsLimited: fills.length >= 2000, ordersLimited: orders.length >= 2000 },
|
|
999
|
+
localOrders: Object.entries(this.store.liveOrders ?? {}).map(([cloid, row]) => ({ cloid, coin: row.coin, status: row.status, at: row.updatedAt ?? row.at, sessionId: row.sessionId })) }
|
|
1000
|
+
if (request?.includeFunding) {
|
|
1001
|
+
try {
|
|
1002
|
+
const funding = await fundingWindow(client, address, start, end)
|
|
1003
|
+
result.funding = funding.rows
|
|
1004
|
+
result.coverage.fundingStatus = funding.status
|
|
1005
|
+
} catch (error) { result.coverage.fundingStatus = 'error'; result.coverage.fundingError = String(error?.message ?? error) }
|
|
1006
|
+
}
|
|
932
1007
|
}
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
const localByCloid = this.store.liveOrders
|
|
940
|
-
const decisions = this.store.liveDecisions
|
|
941
|
-
return { mode: 'live', orders: orders.slice(0, 2000).map((entry) => {
|
|
942
|
-
const order = entry.order ?? entry
|
|
943
|
-
const local = localByCloid[order.cloid]
|
|
944
|
-
return { ...order, status: entry.status ?? local?.status ?? 'unknown', statusTimestamp: entry.statusTimestamp,
|
|
945
|
-
reason: local?.thesis ?? (local?.decisionId ? decisions[local.decisionId]?.thesis ?? null : null),
|
|
946
|
-
sessionId: local?.sessionId ?? (local?.decisionId ? decisions[local.decisionId]?.sessionId ?? null : null) }
|
|
947
|
-
}), fills: fills.slice(0, 2000) }
|
|
1008
|
+
if (requestedStart != null) {
|
|
1009
|
+
result.fills = result.fills.filter((row) => Number(row.time) >= start && Number(row.time) <= end)
|
|
1010
|
+
const ids = new Set(result.fills.filter((row) => row.oid != null).map((row) => `${row.coin}:${row.oid}`))
|
|
1011
|
+
result.orders = result.orders.filter((row) => Number(row.timestamp ?? row.time) >= start || ids.has(`${row.coin}:${row.oid}`))
|
|
1012
|
+
}
|
|
1013
|
+
return { ...result, network, observedAt: Date.now(), coverage: { ...result.coverage, startTime: start, endTime: end, bounded: true } }
|
|
948
1014
|
}
|
|
949
1015
|
|
|
950
1016
|
/** Hyperliquid portfolio samples are display data, not an accounting ledger. */
|
|
@@ -953,7 +1019,10 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
953
1019
|
const account = await this.account({})
|
|
954
1020
|
const fills = this.store.paperAccount().fills
|
|
955
1021
|
return { mode: 'paper', equity: account.equity, unrealizedPnl: account.uPnl,
|
|
956
|
-
realizedPnl: fills.reduce((sum, fill) => sum + Number(fill.realized ?? 0), 0),
|
|
1022
|
+
realizedPnl: fills.reduce((sum, fill) => sum + Number(fill.realized ?? 0), 0),
|
|
1023
|
+
fees: fills.reduce((sum, fill) => sum + Number(fill.fee ?? 0), 0),
|
|
1024
|
+
netRealizedPnl: fills.some((fill) => fill.fee == null) ? null : fills.reduce((sum, fill) => sum + Number(fill.realized ?? 0) - Number(fill.fee), 0),
|
|
1025
|
+
retainedFills: fills.length, series: {} }
|
|
957
1026
|
}
|
|
958
1027
|
const address = await this.resolveAddress()
|
|
959
1028
|
if (!address) throw new Error('未配置交易主账户地址')
|
|
@@ -1142,30 +1211,32 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
1142
1211
|
if (!Number.isFinite(limit) || limit <= 0) throw new Error('限价单需要有效 px')
|
|
1143
1212
|
if ((isBuy && limit < mid) || (!isBuy && limit > mid)) throw new Error('paper 当前只支持可立即成交的限价单')
|
|
1144
1213
|
}
|
|
1145
|
-
const
|
|
1146
|
-
const
|
|
1214
|
+
const costs = this.store.paperCosts()
|
|
1215
|
+
const slip = request?.slippagePct == null ? null : Number(request.slippagePct)
|
|
1216
|
+
const pct = slip != null && Number.isFinite(slip) ? Math.min(Math.max(slip, 0), 10) / 100 : costs.slippageBps / 10_000
|
|
1147
1217
|
const px = mid * (isBuy ? 1 + pct : 1 - pct)
|
|
1148
1218
|
if (request?.type === 'limit' && (isBuy ? px > Number(request.px) : px < Number(request.px))) throw new Error('paper 模拟滑点后无法满足限价')
|
|
1149
1219
|
const paper = this.store.paperAccount()
|
|
1150
|
-
const risk = paperRiskState(paper, { ...await safeMids(this), [coin]:
|
|
1220
|
+
const risk = paperRiskState(paper, { ...await safeMids(this), [coin]: mid }, true)
|
|
1151
1221
|
const positionSize = Number(paper.positions[coin]?.size ?? 0)
|
|
1152
1222
|
const leverage = Number(paper.positions[coin]?.leverage ?? paper.leverageByCoin?.[coin] ?? 1)
|
|
1153
1223
|
const otherExposureUsd = risk.positions.reduce((sum, p) => sum + (p.coin === coin ? 0 : Math.abs(p.size) * p.markPx), 0)
|
|
1154
1224
|
const guard = this.paperGuard ??= new TradingGuard(this.cfg.dataDir, { ...this.cfg, dataDir: this.cfg.dataDir }, { budgetName: this.cfg.network === 'mainnet' ? 'paper-budget-mainnet.json' : 'paper-budget.json' })
|
|
1155
1225
|
const checked = guard.validate({ coin, isBuy, sz, price: px, positionSize, otherExposureUsd, accountValue: risk.equity, leverage, reduceOnly })
|
|
1156
|
-
const currentMargin = Math.abs(positionSize) *
|
|
1157
|
-
const projectedMargin = risk.marginUsed - currentMargin + Math.abs(checked.nextSize) *
|
|
1158
|
-
|
|
1226
|
+
const currentMargin = Math.abs(positionSize) * mid / leverage
|
|
1227
|
+
const projectedMargin = risk.marginUsed - currentMargin + Math.abs(checked.nextSize) * mid / leverage
|
|
1228
|
+
const projectedEquity = risk.equity - checked.notionalUsd * costs.feeBps / 10_000 - sz * (isBuy ? px - mid : mid - px)
|
|
1229
|
+
if (!checked.riskReducing && projectedMargin > projectedEquity + 1e-8) throw new Error('模拟账户可用保证金不足')
|
|
1159
1230
|
const budgetTicket = guard.reserve(checked.notionalUsd, { riskReducing: checked.riskReducing })
|
|
1160
1231
|
let fill
|
|
1161
1232
|
const sessionId = request?.[TOOL_CALL]?.sessionId ?? null
|
|
1162
|
-
try { fill = this.store.paperFill({ coin, isBuy, sz, px, reason: request?.reason ?? 'chat', sessionId }) }
|
|
1233
|
+
try { fill = this.store.paperFill({ coin, isBuy, sz, px, reason: request?.reason ?? 'chat', sessionId, feeBps: costs.feeBps, referencePx: mid, slippageBps: pct * 10_000 }) }
|
|
1163
1234
|
catch (err) { guard.release(budgetTicket); throw err }
|
|
1164
|
-
this.store.paperOrderRecord({ coin, isBuy, sz, px: request?.type === 'limit' ? Number(request.px) : px,
|
|
1235
|
+
this.store.paperOrderRecord({ oid: fill.oid, referencePx: mid, coin, isBuy, sz, px: request?.type === 'limit' ? Number(request.px) : px,
|
|
1165
1236
|
filledPx: px, type: request?.type === 'limit' ? 'limit' : 'market', reduceOnly,
|
|
1166
1237
|
reason: request?.reason ?? '', sessionId })
|
|
1167
|
-
return { mode: 'paper', fill, marginUsed: projectedMargin, availableMargin: Math.max(0,
|
|
1168
|
-
simulation: '
|
|
1238
|
+
return { mode: 'paper', fill, marginUsed: projectedMargin, availableMargin: Math.max(0, projectedEquity - projectedMargin),
|
|
1239
|
+
simulation: '已模拟配置的滑点与手续费;未模拟盘口深度、资金费或强平' }
|
|
1169
1240
|
}
|
|
1170
1241
|
// live
|
|
1171
1242
|
const trusted = request?.[TOOL_CALL]
|
|
@@ -1486,7 +1557,7 @@ function paperRiskState(paper, mids, strict = false) {
|
|
|
1486
1557
|
|
|
1487
1558
|
// Remote markers: every method the web client may call.
|
|
1488
1559
|
for (const m of [
|
|
1489
|
-
'status', 'switchNetwork', 'switchTradingMode', 'updateRiskSettings', 'resetRiskSettings', 'markets', 'marketCatalog', 'marketDetail', 'chartMarks', 'drawChartMark', 'updateChartMark', 'eraseChartMark', 'sessionWorkspace', 'updateSessionWorkspace', 'snapshot', 'candles', 'fundingHistory', 'account', 'fills', 'tradeHistory', 'portfolio',
|
|
1560
|
+
'indicatorTemplates', 'updatePaperCosts', 'status', 'switchNetwork', 'switchTradingMode', 'updateRiskSettings', 'resetRiskSettings', 'markets', 'marketCatalog', 'marketDetail', 'chartMarks', 'drawChartMark', 'updateChartMark', 'eraseChartMark', 'sessionWorkspace', 'updateSessionWorkspace', 'snapshot', 'candles', 'fundingHistory', 'account', 'fills', 'tradeHistory', 'portfolio',
|
|
1490
1561
|
'sessions', 'registerSession', 'removeSession', 'bindCoin', 'deskState',
|
|
1491
1562
|
'eventsSince',
|
|
1492
1563
|
]) {
|
package/lib/review.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export interface ReviewRecord { id: string; coin: string; oid: string | number | null; time: number | null; px: number | null; size: number | null; isBuy: boolean | null; gross: number | null; fee: number | null; feeToken: string; net: number | null; reference: number | null; slippageBps: number | null; closedSize: number | null; reason: string; sessionId: string | null; flags: string[]; source: string }
|
|
2
|
+
export declare function fundingWindow(client: any, address: string, startTime: number, endTime: number): Promise<{rows: any[]; status: string; pages: number}>
|
|
3
|
+
export declare function reviewLedger(payload: any, coin?: string): { records: ReviewRecord[]; orders: any[]; duplicates: number; fees: Record<string, number>; gross: number | null; tradingNet: number | null; funding: number | null; net: number | null; closeCount: number; missingReasons: number; unmatched: number; gaps: number; unsupported: number }
|
package/lib/review.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Evidence over a bounded history window; missing fields remain unknown. No balance inference. */
|
|
2
|
+
const numeric = (value) => value == null || value === '' || !Number.isFinite(Number(value)) ? null : Number(value)
|
|
3
|
+
const key = (row) => row?.oid == null || !row.coin ? null : `${row.coin}:${String(row.oid)}`
|
|
4
|
+
const core = (coin) => typeof coin === 'string' && coin.trim().length > 0 && !coin.startsWith('#') && !coin.includes(':') && !coin.startsWith('@') && !coin.includes('/')
|
|
5
|
+
export async function fundingWindow(client, address, startTime, endTime) {
|
|
6
|
+
const rows = [], seen = new Set(); let cursor = startTime, partial = false
|
|
7
|
+
for (let page = 0; page < 12; page++) {
|
|
8
|
+
const result = await client.info('userFunding', { user: address, startTime: cursor, endTime })
|
|
9
|
+
if (!Array.isArray(result)) throw Error('资金费历史响应无效')
|
|
10
|
+
const valid = result.filter((row) => Number.isFinite(Number(row.time)) && Number(row.time) >= cursor && Number(row.time) <= endTime && row.delta?.type === 'funding')
|
|
11
|
+
if (valid.length !== result.length) partial = true
|
|
12
|
+
for (const row of valid) {
|
|
13
|
+
const id = `${row.time}:${row.hash ?? ''}:${row.delta.coin}:${row.delta.usdc}`
|
|
14
|
+
if (!seen.has(id)) { seen.add(id); rows.push(row) }
|
|
15
|
+
}
|
|
16
|
+
if (result.length < 500) return { rows, status: partial ? 'partial' : 'complete', pages: page + 1 }
|
|
17
|
+
const last = Math.max(...valid.map((row) => Number(row.time)))
|
|
18
|
+
if (!Number.isFinite(last) || last <= cursor) return { rows, status: 'partial', pages: page + 1 }
|
|
19
|
+
// Inclusive boundary: repeat the final timestamp and deduplicate, avoiding lost peers.
|
|
20
|
+
cursor = last
|
|
21
|
+
}
|
|
22
|
+
return { rows, status: 'partial', pages: 12 }
|
|
23
|
+
}
|
|
24
|
+
export function reviewLedger(payload, coin = '') {
|
|
25
|
+
const orders = (payload.orders ?? []).filter((row) => !coin || row.coin === coin)
|
|
26
|
+
const allFills = (payload.fills ?? []).filter((row) => !coin || row.coin === coin)
|
|
27
|
+
const byOrder = new Map(orders.filter((row) => key(row)).map((row) => [key(row), row]))
|
|
28
|
+
const seen = new Set(), records = []; let duplicates = 0
|
|
29
|
+
for (const [index, fill] of allFills.entries()) {
|
|
30
|
+
const id = fill.tid != null ? `${fill.coin}:${fill.tid}` : payload.mode === 'paper' && fill.oid ? `${fill.coin}:${fill.oid}` : `unidentified:${index}`
|
|
31
|
+
if (seen.has(id)) { duplicates++; continue } seen.add(id)
|
|
32
|
+
const order = byOrder.get(key(fill)), pnl = numeric(fill.closedPnl ?? fill.realized), fee = numeric(fill.fee)
|
|
33
|
+
const feeToken = String(fill.feeToken ?? '').trim()
|
|
34
|
+
const px = numeric(fill.px), size = numeric(fill.sz), buy = typeof fill.isBuy === 'boolean' ? fill.isBuy : fill.side === 'B' ? true : fill.side === 'A' ? false : null
|
|
35
|
+
const reference = numeric(fill.referencePx ?? order?.referencePx)
|
|
36
|
+
const reason = fill.reason || order?.reason || ''
|
|
37
|
+
const closed = numeric(fill.closedSize) ?? (buy != null && numeric(fill.startPosition) != null && Math.sign(Number(fill.startPosition)) !== (buy ? 1 : -1) ? Math.min(Math.abs(Number(fill.startPosition)), size ?? 0) : /^Close/.test(fill.dir ?? '') ? size : null)
|
|
38
|
+
const eligible = core(fill.coin) && feeToken === 'USDC'
|
|
39
|
+
const net = eligible && pnl != null && fee != null ? pnl - fee : null
|
|
40
|
+
const flags = []
|
|
41
|
+
if (!order) flags.push('unmatched-order')
|
|
42
|
+
if (px == null || px <= 0) flags.push('missing-price')
|
|
43
|
+
if (size == null || size <= 0) flags.push('missing-size')
|
|
44
|
+
if (numeric(fill.time) == null) flags.push('missing-time')
|
|
45
|
+
if (buy == null) flags.push('missing-side')
|
|
46
|
+
if (!reason || /^(session:|tool$|chat$|order$)/.test(reason)) flags.push('missing-reason')
|
|
47
|
+
if (fee == null || !feeToken) flags.push('missing-fee')
|
|
48
|
+
if (pnl == null) flags.push('missing-pnl')
|
|
49
|
+
if (fill.tid == null && payload.mode !== 'paper') flags.push('missing-trade-id')
|
|
50
|
+
records.push({ id, coin: fill.coin, oid: fill.oid ?? null, time: numeric(fill.time), px, size, isBuy: buy, gross: pnl, fee, feeToken, net,
|
|
51
|
+
reference, slippageBps: reference && reference > 0 && px && buy != null ? (buy ? px - reference : reference - px) / reference * 10_000 : null,
|
|
52
|
+
closedSize: closed, reason: flags.includes('missing-reason') ? '' : reason, sessionId: fill.sessionId ?? order?.sessionId ?? null, flags, source: payload.mode })
|
|
53
|
+
}
|
|
54
|
+
const reconciled = orders.map((order) => {
|
|
55
|
+
const fills = records.filter((row) => key(row) && key(row) === key(order))
|
|
56
|
+
const filled = fills.reduce((sum, row) => sum + (row.size ?? 0), 0)
|
|
57
|
+
const expected = numeric(order.origSz ?? order.sz), tolerance = Math.max(1e-8, (expected ?? 0) * 1e-6)
|
|
58
|
+
const status = fills.some((row) => row.flags.includes('missing-size')) ? 'missing-size' : /filled/i.test(order.status ?? '') && expected != null && Math.abs(filled - expected) > tolerance ? 'size-gap'
|
|
59
|
+
: expected != null && filled > expected + tolerance ? 'excess-fill' : fills.length ? 'matched' : 'no-fill-in-window'
|
|
60
|
+
return { coin: order.coin, oid: order.oid ?? null, status, filled, expected, exchangeStatus: order.status ?? 'unknown', reason: order.reason ?? '' }
|
|
61
|
+
})
|
|
62
|
+
const supported = records.filter((row) => core(row.coin))
|
|
63
|
+
const fees = {}; for (const row of records) if (row.fee != null && row.feeToken) fees[row.feeToken] = (fees[row.feeToken] ?? 0) + row.fee
|
|
64
|
+
const supportedScope = !coin || core(coin)
|
|
65
|
+
const gross = supportedScope ? supported.reduce((sum, row) => sum + (row.gross ?? 0), 0) : null
|
|
66
|
+
const tradingNet = !supportedScope || supported.some((row) => row.net == null) ? null : supported.reduce((sum, row) => sum + row.net, 0)
|
|
67
|
+
const fundingRows = (payload.funding ?? []).filter((row) => core(row.delta?.coin) && (!coin || row.delta.coin === coin))
|
|
68
|
+
const funding = supportedScope && payload.coverage?.fundingStatus === 'complete' && fundingRows.every((row) => numeric(row.delta.usdc) != null)
|
|
69
|
+
? fundingRows.reduce((sum, row) => sum + Number(row.delta.usdc), 0) : null
|
|
70
|
+
const closes = supported.filter((row) => row.closedSize > 0)
|
|
71
|
+
return { records, orders: reconciled, duplicates, fees, gross, tradingNet, funding,
|
|
72
|
+
net: tradingNet != null && funding != null ? tradingNet + funding : null,
|
|
73
|
+
closeCount: closes.length,
|
|
74
|
+
missingReasons: records.filter((row) => row.flags.includes('missing-reason')).length,
|
|
75
|
+
unmatched: records.filter((row) => row.flags.includes('unmatched-order')).length,
|
|
76
|
+
gaps: reconciled.filter((row) => ['size-gap', 'excess-fill'].includes(row.status)).length,
|
|
77
|
+
unsupported: records.length - supported.length,
|
|
78
|
+
}
|
|
79
|
+
}
|
package/lib/store.js
CHANGED
|
@@ -9,14 +9,18 @@ import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, rmSync,
|
|
|
9
9
|
import { dirname, join } from 'node:path'
|
|
10
10
|
import { randomUUID, createHash } from 'node:crypto'
|
|
11
11
|
import { canonicalMarketCoin } from './market-symbol.js'
|
|
12
|
+
import { normalizePaperCosts } from './workspace-settings.js'
|
|
12
13
|
|
|
13
14
|
const PAPER_START_USD = 100_000
|
|
14
15
|
const WORKSPACE_DEFAULTS = Object.freeze({
|
|
15
16
|
interval: '15m', bookGrouping: 'full', chartTab: 'chart', tapeTab: 'book', bottomTab: 'positions', indicators: ['volume'], maLines: [],
|
|
16
|
-
priceScale: 'normal', chartRanges: {}, chartLayers: { orders: true, fills: true, drawings: true },
|
|
17
|
+
priceScale: 'normal', chartType: 'candles', chartRanges: {}, chartLayers: { orders: true, fills: true, drawings: true },
|
|
17
18
|
layout: { sessionsWidth: 232, chatWidth: 480, sessionsCollapsed: false, deskCollapsed: false, chatCollapsed: false },
|
|
18
19
|
})
|
|
19
20
|
|
|
21
|
+
/** Drawings per chart (session + coin, all intervals); the pan budget is verified at this size. */
|
|
22
|
+
export const MAX_CHART_MARKS = 100
|
|
23
|
+
|
|
20
24
|
export class HyperStore {
|
|
21
25
|
constructor(dataDir, paperNetwork = 'testnet') {
|
|
22
26
|
this.dataDir = dataDir
|
|
@@ -255,8 +259,9 @@ export class HyperStore {
|
|
|
255
259
|
}
|
|
256
260
|
|
|
257
261
|
addChartMark(mark) {
|
|
258
|
-
|
|
259
|
-
|
|
262
|
+
// The chart shows a coin's drawings from every interval, so the cap counts them all.
|
|
263
|
+
const related = this.listChartMarks(mark.sessionId, mark.coin)
|
|
264
|
+
if (related.length >= MAX_CHART_MARKS) throw new Error(`当前图表最多保留 ${MAX_CHART_MARKS} 条标注,请先删除旧线`)
|
|
260
265
|
const marks = mark.shared ? this.#shared() : this.#marks(mark.sessionId)
|
|
261
266
|
if (marks.length >= 300) throw new Error('当前会话最多保留 300 条图表标注,请先删除旧线')
|
|
262
267
|
const saved = { ...mark, id: randomUUID(), createdAt: Date.now() }
|
|
@@ -328,16 +333,39 @@ export class HyperStore {
|
|
|
328
333
|
return JSON.parse(JSON.stringify(this.paper))
|
|
329
334
|
}
|
|
330
335
|
|
|
336
|
+
paperCosts() {
|
|
337
|
+
const file = join(this.dataDir, 'paper-costs.json')
|
|
338
|
+
return normalizePaperCosts(existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : undefined)
|
|
339
|
+
}
|
|
340
|
+
setPaperCosts(input) {
|
|
341
|
+
const costs = normalizePaperCosts(input)
|
|
342
|
+
this.#writeJson(join(this.dataDir, 'paper-costs.json'), costs)
|
|
343
|
+
return costs
|
|
344
|
+
}
|
|
345
|
+
indicatorTemplates() { return this.#readJson(join(this.dataDir, 'indicator-templates.json'), []) }
|
|
346
|
+
saveIndicatorTemplate(template) {
|
|
347
|
+
const rows = this.indicatorTemplates()
|
|
348
|
+
if (!rows.some((row) => row.id === template.id) && rows.length >= 20) throw Error('最多保存 20 个指标模板')
|
|
349
|
+
const next = { ...template, id: template.id || randomUUID(), updatedAt: Date.now() }
|
|
350
|
+
this.#writeJson(join(this.dataDir, 'indicator-templates.json'), [...rows.filter((row) => row.id !== next.id), next])
|
|
351
|
+
return next
|
|
352
|
+
}
|
|
353
|
+
removeIndicatorTemplate(id) {
|
|
354
|
+
this.#writeJson(join(this.dataDir, 'indicator-templates.json'), this.indicatorTemplates().filter((row) => row.id !== id))
|
|
355
|
+
}
|
|
356
|
+
|
|
331
357
|
/**
|
|
332
358
|
* Fill a paper order against the given mid with a simple slippage model.
|
|
333
359
|
* @returns the fill record
|
|
334
360
|
*/
|
|
335
|
-
paperFill({ coin, isBuy, sz, px, time = Date.now(), reason = 'order', sessionId = null }) {
|
|
361
|
+
paperFill({ coin, isBuy, sz, px, time = Date.now(), reason = 'order', sessionId = null, feeBps = 0, referencePx = null, slippageBps = null }) {
|
|
336
362
|
const szNum = Number(sz)
|
|
337
363
|
const pxNum = Number(px)
|
|
338
364
|
if (!Number.isFinite(szNum) || szNum <= 0) throw new Error('paper: 无效数量')
|
|
339
365
|
if (!Number.isFinite(pxNum) || pxNum <= 0) throw new Error('paper: 无效价格')
|
|
340
366
|
const notional = szNum * pxNum
|
|
367
|
+
if (!Number.isFinite(feeBps) || feeBps < 0 || feeBps > 100) throw Error('paper: 无效手续费')
|
|
368
|
+
const fee = notional * feeBps / 10_000
|
|
341
369
|
const pos = this.paper.positions[coin] ?? { size: 0, entryPx: pxNum, leverage: this.paper.leverageByCoin?.[coin] ?? 1 }
|
|
342
370
|
const signed = isBuy ? szNum : -szNum
|
|
343
371
|
const prevSize = pos.size
|
|
@@ -360,11 +388,13 @@ export class HyperStore {
|
|
|
360
388
|
pos.size = newSize
|
|
361
389
|
this.paper.positions[coin] = pos
|
|
362
390
|
}
|
|
363
|
-
this.paper.usd += realized
|
|
391
|
+
this.paper.usd += realized - fee
|
|
364
392
|
// sessionId: the trading conversation whose decision produced this fill.
|
|
365
|
-
const fill = { coin, isBuy, sz: szNum, px: pxNum, notional, realized,
|
|
393
|
+
const fill = { oid: `paper-${randomUUID()}`, coin, isBuy, sz: szNum, px: pxNum, notional, realized, closedPnl: realized,
|
|
394
|
+
fee, feeToken: 'USDC', netPnl: realized - fee, feeBps, referencePx, slippageBps,
|
|
395
|
+
closedSize: Math.min(Math.abs(prevSize), Math.sign(prevSize) !== Math.sign(signed) ? szNum : 0), startPosition: prevSize, time, reason, sessionId }
|
|
366
396
|
this.paper.fills.unshift(fill)
|
|
367
|
-
if (this.paper.fills.length >
|
|
397
|
+
if (this.paper.fills.length > 5000) this.paper.fills.length = 5000
|
|
368
398
|
this.#writeJson(this.paperFile, this.paper)
|
|
369
399
|
this.recordEvent('paper.fill', fill)
|
|
370
400
|
return fill
|
|
@@ -373,7 +403,7 @@ export class HyperStore {
|
|
|
373
403
|
paperOrderRecord(order) {
|
|
374
404
|
const row = { ...order, time: Date.now(), status: 'filled' }
|
|
375
405
|
this.paper.orders.unshift(row)
|
|
376
|
-
if (this.paper.orders.length >
|
|
406
|
+
if (this.paper.orders.length > 5000) this.paper.orders.length = 5000
|
|
377
407
|
this.#writeJson(this.paperFile, this.paper)
|
|
378
408
|
this.recordEvent('paper.order', row)
|
|
379
409
|
}
|
package/lib/studies.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface IndicatorSettings {
|
|
2
|
+
rsi: { period: number; lower: number; upper: number }
|
|
3
|
+
macd: { fast: number; slow: number; signal: number }
|
|
4
|
+
bollinger: { period: number; multiplier: number }
|
|
5
|
+
stochastic: { period: number; smoothK: number; smoothD: number; lower: number; upper: number }
|
|
6
|
+
adx: { period: number; smoothing: number }
|
|
7
|
+
}
|
|
8
|
+
export type StudyKey = 'sma20' | 'sma60' | 'ema20' | 'ema50' | 'basis' | 'upper' | 'lower' | 'rsi14' | 'macd' | 'signal' | 'histogram' | 'atr14' | 'vwap' | 'stochK' | 'stochD' | 'adx' | 'plusDI' | 'minusDI' | 'obv'
|
|
9
|
+
export declare const INDICATOR_IDS: string[]
|
|
10
|
+
export declare const DEFAULT_INDICATOR_SETTINGS: IndicatorSettings
|
|
11
|
+
export declare function normalizeIndicatorSettings(input?: unknown): IndicatorSettings
|
|
12
|
+
export declare function computeStudies(candles: readonly {t: number; o: number; h: number; l: number; c: number; v: number}[], input?: IndicatorSettings): Record<StudyKey, Array<{ time: number; value: number }>>
|
|
13
|
+
export declare function studyDefinitions(input?: IndicatorSettings): Array<[string, StudyKey, string]>
|