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.
@@ -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
- '可用工具:hyper_context(会话交易上下文)、hyper_market(详细行情)、hyper_chart(在行情图上画支撑/阻力与趋势线)、hyper_account(账户)、hyper_decide(交易决策预检并返回一次性 decisionId)、hyper_order(执行/撤单/杠杆)、hyper_session(会话视角)。',
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。')
@@ -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
@@ -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 = ['volume', 'sma20', 'sma60', 'ema20', 'ema50', 'bollinger20', 'rsi14', 'macd']
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
- return this.addChartMark(request, sessionId, 'user')
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
- if (this.cfg.tradingMode === 'paper') {
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
- return { mode: 'paper', orders: paper.orders.slice(0, 200), fills: paper.fills.slice(0, 200) }
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
- const address = await this.resolveAddress()
934
- if (!address) throw new Error('未配置交易主账户地址')
935
- const [orders, fills] = await Promise.all([
936
- this.client.historicalOrders(address), this.client.userFills(address),
937
- ])
938
- if (!Array.isArray(orders) || !Array.isArray(fills)) throw new Error('交易所历史数据响应无效')
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), series: {} }
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 slip = Number(request?.slippagePct)
1146
- const pct = Number.isFinite(slip) ? Math.min(Math.max(slip, 0), 10) / 100 : 0.0005
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]: px }, true)
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) * px / leverage
1157
- const projectedMargin = risk.marginUsed - currentMargin + Math.abs(checked.nextSize) * px / leverage
1158
- if (!checked.riskReducing && projectedMargin > risk.equity + 1e-8) throw new Error('模拟账户可用保证金不足')
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, risk.equity - projectedMargin),
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
  ]) {
@@ -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
- const related = this.listChartMarks(mark.sessionId, mark.coin, mark.interval)
259
- if (related.length >= 30) throw new Error('当前图表最多保留 30 条标注,请先删除旧线')
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 // realized PnL settles into cash; margin tracked conceptually
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, time, reason, sessionId }
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 > 200) this.paper.fills.length = 200
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 > 200) this.paper.orders.length = 200
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
  }
@@ -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]>