dsh-hyperliquid 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -1,3 +1,4 @@
1
+ import { canonicalMarketCoin } from './market-symbol.js'
1
2
  /** Validate the user-editable trading limits before they reach either guard. */
2
3
  export function normalizeRiskSettings(input) {
3
4
  if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('交易设置无效')
@@ -5,7 +6,7 @@ export function normalizeRiskSettings(input) {
5
6
  if (Object.keys(input).some((key) => !allowed.includes(key)) || allowed.some((key) => !(key in input))) throw new Error('交易设置字段无效')
6
7
  const coins = typeof input.allowedCoins === 'string' ? input.allowedCoins.split(',') : input.allowedCoins
7
8
  if (!Array.isArray(coins)) throw new Error('允许交易的币对无效')
8
- const allowedCoins = [...new Set(coins.map((coin) => String(coin).trim()).filter(Boolean))]
9
+ const allowedCoins = [...new Set(coins.map((coin) => canonicalMarketCoin(coin)).filter(Boolean))]
9
10
  if (!allowedCoins.length || allowedCoins.length > 100 || allowedCoins.some((coin) => coin.length > 32 || !/^[A-Za-z0-9:_-]+$/.test(coin))) {
10
11
  throw new Error('允许交易的币对无效')
11
12
  }
package/lib/scan.js ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * dsh-hyperliquid — market radar (市场雷达). Scans the most liquid tradable perps (core + USDC HIP-3)
3
+ * for a few plain, verifiable signals and returns the top few of each with the data time, so the
4
+ * agent opens a conversation with something concrete instead of a questionnaire.
5
+ * Kept deliberately small (codex review): ~20 markets, cached, no OI-anomaly claims without a baseline.
6
+ * @module lib/scan
7
+ */
8
+
9
+ import { closedCandles } from './indicators.js'
10
+
11
+ const num = (value) => value == null || value === '' || !Number.isFinite(Number(value)) ? null : Number(value)
12
+
13
+ /**
14
+ * Pure part: rank markets by signal. `ranges` maps coin → { high, low, bars } of the last closed 1h bars.
15
+ * @returns {{ funding, movers, premium, levels }} top entries per signal
16
+ */
17
+ export function rankSignals(markets, ranges = {}, { top = 3 } = {}) {
18
+ const rows = markets.map((m) => {
19
+ const ctx = m.ctx ?? {}
20
+ const mark = num(ctx.markPx), prev = num(ctx.prevDayPx), oracle = num(ctx.oraclePx), funding = num(ctx.funding)
21
+ return { coin: m.name, kind: m.kind, mark, change24h: mark && prev ? mark / prev - 1 : null,
22
+ fundingApr: funding == null ? null : funding * 24 * 365, premium: mark && oracle ? mark / oracle - 1 : null, volume: num(ctx.dayNtlVlm) }
23
+ }).filter((row) => row.mark != null)
24
+ const pick = (list, score) => list.filter((row) => score(row) != null).sort((a, b) => Math.abs(score(b)) - Math.abs(score(a))).slice(0, top)
25
+ const levels = rows.map((row) => {
26
+ const range = ranges[row.coin]
27
+ if (!range || !(range.high > range.low)) return null
28
+ const toHigh = range.high / row.mark - 1, toLow = row.mark / range.low - 1
29
+ const side = toHigh <= toLow ? 'high' : 'low'
30
+ return { ...row, side, distance: Math.min(Math.abs(toHigh), Math.abs(toLow)), high: range.high, low: range.low, bars: range.bars, broken: row.mark > range.high || row.mark < range.low }
31
+ }).filter(Boolean).sort((a, b) => (Number(b.broken) - Number(a.broken)) || a.distance - b.distance).slice(0, top)
32
+ return {
33
+ funding: pick(rows, (row) => row.fundingApr),
34
+ movers: pick(rows, (row) => row.change24h),
35
+ premium: pick(rows.filter((row) => row.kind === 'hip3' || Math.abs(row.premium ?? 0) > 0.001), (row) => row.premium),
36
+ levels,
37
+ }
38
+ }
39
+
40
+ const cache = new Map()
41
+
42
+ /** Live part: pick the liquid tradable perps, read their last 20 closed 1h bars, rank. Cached 60s. */
43
+ export async function scanMarkets(remote, { limit = 20, minVolumeUsd = 1_000_000 } = {}) {
44
+ const network = remote.cfg?.network, client = remote.client
45
+ const assertCurrent = () => { if (remote.cfg?.network !== network || remote.client !== client) throw new Error('网络已切换,请重新扫描') }
46
+ const key = `${network}:${limit}:${minVolumeUsd}`
47
+ const hit = cache.get(key)
48
+ if (hit && Date.now() - hit.at < 60_000) return hit.result
49
+ const catalog = await remote.marketCatalog()
50
+ assertCurrent()
51
+ // HIP-3 contexts normally arrive over the websocket; when the catalog has none, read each dex once.
52
+ const missing = [...new Set(catalog.markets.filter((m) => m.kind === 'hip3' && m.tradable && !m.ctx).map((m) => m.name.split(':')[0]))]
53
+ const filled = new Map()
54
+ await Promise.all(missing.map(async (dex) => {
55
+ try {
56
+ const [meta, ctxs] = await client.info('metaAndAssetCtxs', { dex })
57
+ for (const [index, asset] of (meta?.universe ?? []).entries()) if (ctxs?.[index]) filled.set(asset.name, ctxs[index])
58
+ } catch { /* that dex simply stays out of this scan */ }
59
+ }))
60
+ assertCurrent()
61
+ if (filled.size) catalog.markets = catalog.markets.map((m) => !m.ctx && filled.has(m.name) ? { ...m, ctx: filled.get(m.name) } : m)
62
+ // Core perps dominate volume; reserve a quarter of the slots for HIP-3 (indices, stocks, commodities).
63
+ const liquid = (kind) => catalog.markets.filter((m) => m.kind === kind && m.tradable && num(m.ctx?.dayNtlVlm) >= minVolumeUsd)
64
+ .sort((a, b) => num(b.ctx.dayNtlVlm) - num(a.ctx.dayNtlVlm))
65
+ const hip3 = liquid('hip3').slice(0, Math.floor(limit / 4))
66
+ const markets = [...liquid('perp').slice(0, limit - hip3.length), ...hip3]
67
+ const ranges = {}
68
+ await Promise.all(markets.map(async (m) => {
69
+ try {
70
+ const { candles } = await remote.candles({ coin: m.name, interval: '1h' })
71
+ // Closed bars only: the forming bar is not part of the range it might be breaking.
72
+ const rows = closedCandles(candles, { interval: '1h' }).rows.slice(-20)
73
+ if (rows.length >= 20) ranges[m.name] = { high: Math.max(...rows.map((r) => r.h)), low: Math.min(...rows.map((r) => r.l)), bars: rows.length }
74
+ } catch { /* one market's candles must not sink the scan */ }
75
+ }))
76
+ assertCurrent()
77
+ const result = { ...rankSignals(markets, ranges), scanned: markets.length, observedAt: catalog.ctxObservedAt ?? catalog.observedAt ?? Date.now(), network }
78
+ cache.set(key, { at: Date.now(), result })
79
+ return result
80
+ }
81
+
82
+ export function scanText(result) {
83
+ const pct = (v, d = 2) => v == null ? '—' : `${v >= 0 ? '+' : ''}${(v * 100).toFixed(d)}%`
84
+ const lines = [`市场雷达 · 扫描 ${result.scanned} 个可交易永续(成交额最大的核心永续,另留 1/4 名额给成交额最大的 HIP-3)· 数据时间 ${new Date(result.observedAt).toISOString().slice(0, 16)}Z`]
85
+ if (result.levels.length) lines.push('贴近/突破 20 根 1h 区间:' + result.levels.map((r) => `${r.coin} ${r.broken ? (r.mark > r.high ? '已上破' : '已下破') : `距${r.side === 'high' ? '高点' : '低点'} ${(r.distance * 100).toFixed(2)}%`}(区间 ${r.low}–${r.high})`).join(';'))
86
+ if (result.funding.length) lines.push('资金费极值(年化):' + result.funding.map((r) => `${r.coin} ${pct(r.fundingApr, 1)}${r.fundingApr > 0 ? '(多头付费)' : '(空头付费)'}`).join(';'))
87
+ if (result.movers.length) lines.push('24h 涨跌最大:' + result.movers.map((r) => `${r.coin} ${pct(r.change24h)}`).join(';'))
88
+ if (result.premium.length) lines.push('标记价相对预言机:' + result.premium.map((r) => `${r.coin} ${pct(r.premium, 3)}`).join(';'))
89
+ lines.push('下一步:只挑 1 个与用户偏好最匹配的机会,用 hyper_market candles/indicators 核实,再用 hyper_chart 画出区间和 long/short 头寸框给用户看;雷达只是线索,不是交易信号。')
90
+ return lines.join('\n')
91
+ }
package/lib/signing.js CHANGED
@@ -213,3 +213,61 @@ export async function addressFromPrivateKey(privateKey) {
213
213
  const hash = c.keccak256(pub.slice(1))
214
214
  return '0x' + c.bytesToHex(hash.slice(-20))
215
215
  }
216
+
217
+ // ── user-signed actions (EIP-712 "HyperliquidSignTransaction" domain) ──
218
+ // Field lists and order follow the official Python SDK (utils/signing.py); the main wallet signs them.
219
+ export const SEND_ASSET_TYPES = [
220
+ { name: 'hyperliquidChain', type: 'string' },
221
+ { name: 'destination', type: 'string' },
222
+ { name: 'sourceDex', type: 'string' },
223
+ { name: 'destinationDex', type: 'string' },
224
+ { name: 'token', type: 'string' },
225
+ { name: 'amount', type: 'string' },
226
+ { name: 'fromSubAccount', type: 'string' },
227
+ { name: 'nonce', type: 'uint64' },
228
+ ]
229
+ /** The SDK signs user actions on chain 0x66eee; hyperliquidChain inside the message prevents replay across networks. */
230
+ export const USER_SIGNATURE_CHAIN_ID = '0x66eee'
231
+
232
+ /**
233
+ * A `sendAsset` action (move collateral between perp dexes / spot) and its EIP-712 payload.
234
+ * @returns {{ action, typed: { domain, types, value } }}
235
+ */
236
+ export function buildSendAsset({ destination, sourceDex, destinationDex, token, amount, nonce, isMainnet = true }) {
237
+ const action = { type: 'sendAsset', signatureChainId: USER_SIGNATURE_CHAIN_ID, hyperliquidChain: isMainnet ? 'Mainnet' : 'Testnet',
238
+ destination, sourceDex, destinationDex, token, amount, fromSubAccount: '', nonce }
239
+ const value = Object.fromEntries(SEND_ASSET_TYPES.map(({ name }) => [name, action[name]]))
240
+ return {
241
+ action,
242
+ typed: {
243
+ domain: { name: 'HyperliquidSignTransaction', version: '1', chainId: parseInt(USER_SIGNATURE_CHAIN_ID, 16), verifyingContract: '0x0000000000000000000000000000000000000000' },
244
+ types: { 'HyperliquidTransaction:SendAsset': SEND_ASSET_TYPES },
245
+ value,
246
+ },
247
+ }
248
+ }
249
+
250
+ /** EIP-712 digest + secp256k1 signature for a single-struct payload of string / uint / bool / address fields. */
251
+ export async function signTypedDataLocal(privateKey, { domain, types, value }) {
252
+ const c = await loadCrypto()
253
+ const [primary, fields] = Object.entries(types)[0]
254
+ const encode = (type, item) => {
255
+ if (type === 'string') return c.hashText(String(item))
256
+ if (type === 'bool') return u256be(item ? 1 : 0)
257
+ if (type === 'address') { const out = new Uint8Array(32); out.set(addressBytes(item), 12); return out }
258
+ if (/^uint\d+$/.test(type)) return u256be(item)
259
+ throw new Error(`unsupported EIP-712 field type ${type}`)
260
+ }
261
+ const typeHash = c.hashText(`${primary}(${fields.map((f) => `${f.type} ${f.name}`).join(',')})`)
262
+ const structHash = c.keccak256(concat(typeHash, ...fields.map((f) => encode(f.type, value[f.name]))))
263
+ const domainHash = c.keccak256(concat(c.hashText('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'),
264
+ c.hashText(domain.name), c.hashText(domain.version), u256be(domain.chainId), encode('address', domain.verifyingContract)))
265
+ const digest = c.keccak256(concat(new Uint8Array([0x19, 0x01]), domainHash, structHash))
266
+ const priv = c.hexToBytes(privateKey)
267
+ if (priv.length !== 32) throw new Error('钱包私钥格式错误(需要 32 字节 hex)')
268
+ const sig = c.sign(digest, priv)
269
+ const recovery = typeof sig.recovery === 'number' ? sig.recovery : sig.v - 27
270
+ const rHex = c.bytesToHex(typeof sig.r === 'bigint' ? u256be(sig.r) : sig.r).padStart(64, '0').slice(-64)
271
+ const sHex = c.bytesToHex(typeof sig.s === 'bigint' ? u256be(sig.s) : sig.s).padStart(64, '0').slice(-64)
272
+ return { r: '0x' + rHex, s: '0x' + sHex, v: 27 + recovery }
273
+ }
package/lib/skill.js CHANGED
@@ -10,8 +10,8 @@ export function registerHyperSkill(skills) {
10
10
  if (typeof skills?.register !== 'function') return null
11
11
  return skills.register({
12
12
  name: 'hyperliquid-trading',
13
- description: '分析 Hyperliquid 实时行情、账户和风险,并在 DSH 交易会话中安全决策、下单、撤单和对账。',
14
- whenToUse: '讨论当前交易台、Hyperliquid 市场、持仓、订单、paper 或 testnet/live 交易时使用。',
13
+ description: '交易副手:用 Hyperliquid 实时数据分析、规划、执行、对账与复盘,积累和迭代交易员的策略库;说明可交易市场、白名单、HIP-3 保证金等能力边界。',
14
+ whenToUse: '讨论当前交易台、Hyperliquid 市场、持仓、订单、策略、复盘、paper 或 testnet/live 交易时使用。',
15
15
  source: 'bundled',
16
16
  path: fileURLToPath(skillUrl),
17
17
  content,
package/lib/store.js CHANGED
@@ -9,14 +9,19 @@ 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. */import { emptyJournal } from './journal.js'
22
+
23
+ export const MAX_CHART_MARKS = 100
24
+
20
25
  export class HyperStore {
21
26
  constructor(dataDir, paperNetwork = 'testnet') {
22
27
  this.dataDir = dataDir
@@ -255,8 +260,9 @@ export class HyperStore {
255
260
  }
256
261
 
257
262
  addChartMark(mark) {
258
- const related = this.listChartMarks(mark.sessionId, mark.coin, mark.interval)
259
- if (related.length >= 30) throw new Error('当前图表最多保留 30 条标注,请先删除旧线')
263
+ // The chart shows a coin's drawings from every interval, so the cap counts them all.
264
+ const related = this.listChartMarks(mark.sessionId, mark.coin)
265
+ if (related.length >= MAX_CHART_MARKS) throw new Error(`当前图表最多保留 ${MAX_CHART_MARKS} 条标注,请先删除旧线`)
260
266
  const marks = mark.shared ? this.#shared() : this.#marks(mark.sessionId)
261
267
  if (marks.length >= 300) throw new Error('当前会话最多保留 300 条图表标注,请先删除旧线')
262
268
  const saved = { ...mark, id: randomUUID(), createdAt: Date.now() }
@@ -328,16 +334,68 @@ export class HyperStore {
328
334
  return JSON.parse(JSON.stringify(this.paper))
329
335
  }
330
336
 
337
+ paperCosts() {
338
+ const file = join(this.dataDir, 'paper-costs.json')
339
+ return normalizePaperCosts(existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : undefined)
340
+ }
341
+ setPaperCosts(input) {
342
+ const costs = normalizePaperCosts(input)
343
+ this.#writeJson(join(this.dataDir, 'paper-costs.json'), costs)
344
+ return costs
345
+ }
346
+ /** Signing set on the desk (signer, web3Account, tradingAddress); overrides plugin config, no restart. */
347
+ walletSettings() { return this.#readJson(join(this.dataDir, 'wallet-settings.json'), null) }
348
+ saveWalletSettings(settings) { this.#writeJson(join(this.dataDir, 'wallet-settings.json'), { ...settings, at: Date.now() }); return settings }
349
+
350
+ /** Whether a session's current turn was started by an alert (see lib/turn-origin.js); survives a plugin reload. */
351
+ setAlertOrigin(sessionId, alert) {
352
+ const rows = { ...this.#readJson(join(this.dataDir, 'turn-origin.json'), {}), [sessionId]: { alert: Boolean(alert), at: Date.now() } }
353
+ // Only cleared rows are pruned: a live alert origin stays until a human message retires it.
354
+ const cleared = Object.entries(rows).filter(([, row]) => !row.alert).sort((a, b) => b[1].at - a[1].at).slice(0, 500)
355
+ const kept = Object.fromEntries([...Object.entries(rows).filter(([, row]) => row.alert), ...cleared])
356
+ this.#writeJson(join(this.dataDir, 'turn-origin.json'), kept)
357
+ }
358
+ alertOrigin(sessionId) { const row = this.#readJson(join(this.dataDir, 'turn-origin.json'), {})[sessionId]; return row ? row.alert === true : undefined }
359
+
360
+ /** Custody mode chosen on the desk: 'managed' (全托管) or 'assisted' (半托管); null = not chosen yet. */
361
+ custody() {
362
+ const saved = this.#readJson(join(this.dataDir, 'custody.json'), null)
363
+ return saved?.mode === 'managed' || saved?.mode === 'assisted' ? saved.mode : null
364
+ }
365
+ saveCustody(mode) { this.#writeJson(join(this.dataDir, 'custody.json'), { mode, at: Date.now() }); return mode }
366
+
367
+ /** The trader's journal (profile, strategy book, trade/review log); see lib/journal.js. */
368
+ journal() {
369
+ const saved = this.#readJson(join(this.dataDir, 'journal.json'), null)
370
+ return saved && typeof saved === 'object' && Array.isArray(saved.strategies) && Array.isArray(saved.entries)
371
+ ? { profile: saved.profile ?? {}, strategies: saved.strategies, entries: saved.entries } : emptyJournal()
372
+ }
373
+ saveJournal(journal) { this.#writeJson(join(this.dataDir, 'journal.json'), journal); return journal }
374
+
375
+ indicatorTemplates() { return this.#readJson(join(this.dataDir, 'indicator-templates.json'), []) }
376
+ saveIndicatorTemplate(template) {
377
+ const rows = this.indicatorTemplates()
378
+ if (!rows.some((row) => row.id === template.id) && rows.length >= 20) throw Error('最多保存 20 个指标模板')
379
+ const next = { ...template, id: template.id || randomUUID(), updatedAt: Date.now() }
380
+ this.#writeJson(join(this.dataDir, 'indicator-templates.json'), [...rows.filter((row) => row.id !== next.id), next])
381
+ return next
382
+ }
383
+ removeIndicatorTemplate(id) {
384
+ this.#writeJson(join(this.dataDir, 'indicator-templates.json'), this.indicatorTemplates().filter((row) => row.id !== id))
385
+ }
386
+
331
387
  /**
332
388
  * Fill a paper order against the given mid with a simple slippage model.
333
389
  * @returns the fill record
334
390
  */
335
- paperFill({ coin, isBuy, sz, px, time = Date.now(), reason = 'order', sessionId = null }) {
391
+ paperFill({ coin, isBuy, sz, px, time = Date.now(), reason = 'order', sessionId = null, feeBps = 0, referencePx = null, slippageBps = null }) {
336
392
  const szNum = Number(sz)
337
393
  const pxNum = Number(px)
338
394
  if (!Number.isFinite(szNum) || szNum <= 0) throw new Error('paper: 无效数量')
339
395
  if (!Number.isFinite(pxNum) || pxNum <= 0) throw new Error('paper: 无效价格')
340
396
  const notional = szNum * pxNum
397
+ if (!Number.isFinite(feeBps) || feeBps < 0 || feeBps > 100) throw Error('paper: 无效手续费')
398
+ const fee = notional * feeBps / 10_000
341
399
  const pos = this.paper.positions[coin] ?? { size: 0, entryPx: pxNum, leverage: this.paper.leverageByCoin?.[coin] ?? 1 }
342
400
  const signed = isBuy ? szNum : -szNum
343
401
  const prevSize = pos.size
@@ -360,11 +418,13 @@ export class HyperStore {
360
418
  pos.size = newSize
361
419
  this.paper.positions[coin] = pos
362
420
  }
363
- this.paper.usd += realized // realized PnL settles into cash; margin tracked conceptually
421
+ this.paper.usd += realized - fee
364
422
  // sessionId: the trading conversation whose decision produced this fill.
365
- const fill = { coin, isBuy, sz: szNum, px: pxNum, notional, realized, time, reason, sessionId }
423
+ const fill = { oid: `paper-${randomUUID()}`, coin, isBuy, sz: szNum, px: pxNum, notional, realized, closedPnl: realized,
424
+ fee, feeToken: 'USDC', netPnl: realized - fee, feeBps, referencePx, slippageBps,
425
+ closedSize: Math.min(Math.abs(prevSize), Math.sign(prevSize) !== Math.sign(signed) ? szNum : 0), startPosition: prevSize, time, reason, sessionId }
366
426
  this.paper.fills.unshift(fill)
367
- if (this.paper.fills.length > 200) this.paper.fills.length = 200
427
+ if (this.paper.fills.length > 5000) this.paper.fills.length = 5000
368
428
  this.#writeJson(this.paperFile, this.paper)
369
429
  this.recordEvent('paper.fill', fill)
370
430
  return fill
@@ -373,7 +433,7 @@ export class HyperStore {
373
433
  paperOrderRecord(order) {
374
434
  const row = { ...order, time: Date.now(), status: 'filled' }
375
435
  this.paper.orders.unshift(row)
376
- if (this.paper.orders.length > 200) this.paper.orders.length = 200
436
+ if (this.paper.orders.length > 5000) this.paper.orders.length = 5000
377
437
  this.#writeJson(this.paperFile, this.paper)
378
438
  this.recordEvent('paper.order', row)
379
439
  }
@@ -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]>
package/lib/studies.js ADDED
@@ -0,0 +1,157 @@
1
+ /** Shared chart/agent study definitions. All values are derived from the supplied candle window. */
2
+ export const INDICATOR_IDS = ['volume', 'sma20', 'sma60', 'ema20', 'ema50', 'bollinger20', 'rsi14', 'macd', 'atr14', 'vwap', 'stochastic', 'adx', 'obv']
3
+ export const DEFAULT_INDICATOR_SETTINGS = {
4
+ rsi: { period: 14, lower: 30, upper: 70 }, macd: { fast: 12, slow: 26, signal: 9 },
5
+ bollinger: { period: 20, multiplier: 2 }, stochastic: { period: 14, smoothK: 3, smoothD: 3, lower: 20, upper: 80 },
6
+ adx: { period: 14, smoothing: 14 },
7
+ }
8
+ export function normalizeIndicatorSettings(input = {}) {
9
+ if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some((key) => !Object.hasOwn(DEFAULT_INDICATOR_SETTINGS, key))) throw Error('指标参数无效')
10
+ const result = {}
11
+ for (const [section, defaults] of Object.entries(DEFAULT_INDICATOR_SETTINGS)) {
12
+ const supplied = input[section] ?? {}
13
+ if (typeof supplied !== 'object' || Array.isArray(supplied) || Object.keys(supplied).some((key) => !Object.hasOwn(defaults, key))) throw Error('指标参数字段无效')
14
+ const values = { ...defaults, ...supplied }
15
+ for (const [key, value] of Object.entries(values)) {
16
+ if (typeof value !== 'number' || !Number.isFinite(value)) throw Error(`${section}.${key} 必须为数值`)
17
+ if (key === 'multiplier') { if (value <= 0 || value > 10) throw Error('布林带倍数必须大于 0 且不超过 10') }
18
+ else if (key === 'lower' || key === 'upper') { if (value < 0 || value > 100) throw Error('指标阈值必须为 0–100') }
19
+ else if (!Number.isInteger(value) || value < 1 || value > 500) throw Error('指标周期必须为 1–500 的整数')
20
+ }
21
+ if ('lower' in values && values.lower >= values.upper) throw Error('指标下阈值必须小于上阈值')
22
+ result[section] = values
23
+ }
24
+ if (result.macd.fast >= result.macd.slow) throw Error('MACD 快线周期必须小于慢线周期')
25
+ return result
26
+ }
27
+ function nullableSma(values, period) {
28
+ return values.map((_, i) => {
29
+ if (i < period - 1) return null
30
+ const window = values.slice(i - period + 1, i + 1)
31
+ return window.some((value) => value == null) ? null : window.reduce((sum, value) => sum + value, 0) / period
32
+ })
33
+ }
34
+ function rma(values, period) {
35
+ let prior = null, seed = []
36
+ return values.map((value) => {
37
+ if (value == null) return null
38
+ if (prior == null) { seed.push(value); if (seed.length < period) return null; prior = seed.reduce((sum, item) => sum + item, 0) / period }
39
+ else prior = (prior * (period - 1) + value) / period
40
+ return prior
41
+ })
42
+ }
43
+ export function studyDefinitions(input) {
44
+ const p = normalizeIndicatorSettings(input)
45
+ return [
46
+ ['sma20', 'sma20', 'SMA20'], ['sma60', 'sma60', 'MA60'], ['ema20', 'ema20', 'EMA20'], ['ema50', 'ema50', 'EMA50'],
47
+ ['bollinger20', 'basis', `BOLL ${p.bollinger.period} × ${p.bollinger.multiplier}`], ['bollinger20', 'upper', 'BOLL upper'], ['bollinger20', 'lower', 'BOLL lower'],
48
+ ['rsi14', 'rsi14', `RSI${p.rsi.period}`], ['macd', 'macd', p.macd.fast === 12 && p.macd.slow === 26 && p.macd.signal === 9 ? 'MACD' : `MACD ${p.macd.fast}/${p.macd.slow}/${p.macd.signal}`], ['macd', 'signal', 'Signal'], ['macd', 'histogram', 'Hist'],
49
+ ['atr14', 'atr14', 'ATR14'], ['vwap', 'vwap', 'VWAP (UTC HLC3)'], ['stochastic', 'stochK', `Stoch %K ${p.stochastic.period}/${p.stochastic.smoothK}`], ['stochastic', 'stochD', `Stoch %D ${p.stochastic.smoothD}`],
50
+ ['adx', 'adx', `ADX ${p.adx.period}/${p.adx.smoothing}`], ['adx', 'plusDI', '+DI'], ['adx', 'minusDI', '-DI'], ['obv', 'obv', 'OBV'],
51
+ ]
52
+ }
53
+ function points(rows, values) {
54
+ return values.flatMap((value, index) => value == null || !Number.isFinite(value)
55
+ ? [] : [{ time: Math.floor(rows[index].t / 1000), value }])
56
+ }
57
+
58
+ function sma(values, period) {
59
+ let sum = 0
60
+ return values.map((value, index) => {
61
+ sum += value
62
+ if (index >= period) sum -= values[index - period]
63
+ return index >= period - 1 ? sum / period : null
64
+ })
65
+ }
66
+
67
+ function ema(values, period) {
68
+ const result = Array(values.length).fill(null)
69
+ const alpha = 2 / (period + 1)
70
+ let seed = []
71
+ let prior = null
72
+ for (let i = 0; i < values.length; i += 1) {
73
+ const value = values[i]
74
+ if (value == null) continue
75
+ if (prior == null) {
76
+ seed.push(value)
77
+ if (seed.length < period) continue
78
+ prior = seed.reduce((total, current) => total + current, 0) / period
79
+ } else prior = value * alpha + prior * (1 - alpha)
80
+ result[i] = prior
81
+ }
82
+ return result
83
+ }
84
+
85
+ export function computeStudies(candles, input) {
86
+ const settings = normalizeIndicatorSettings(input)
87
+ const rows = candles.filter((row) => Number.isFinite(row.c) && row.c > 0 && Number.isFinite(row.t))
88
+ const closes = rows.map((row) => row.c)
89
+ const mean = sma(closes, settings.bollinger.period)
90
+ const ema12 = ema(closes, settings.macd.fast)
91
+ const ema26 = ema(closes, settings.macd.slow)
92
+ const macd = ema12.map((value, index) => value == null || ema26[index] == null ? null : value - ema26[index])
93
+ const signal = ema(macd, settings.macd.signal)
94
+ const deviation = mean.map((value, index) => value == null ? null : Math.sqrt(
95
+ closes.slice(index - settings.bollinger.period + 1, index + 1).reduce((total, close) => total + (close - value) ** 2, 0) / settings.bollinger.period,
96
+ ))
97
+ const rsi = Array(rows.length).fill(null)
98
+ let gain = 0; let loss = 0
99
+ for (let i = 1; i < rows.length; i += 1) {
100
+ const delta = closes[i] - closes[i - 1]
101
+ const up = Math.max(delta, 0); const down = Math.max(-delta, 0)
102
+ if (i <= settings.rsi.period) { gain += up; loss += down }
103
+ else { gain = (gain * (settings.rsi.period - 1) + up) / settings.rsi.period; loss = (loss * (settings.rsi.period - 1) + down) / settings.rsi.period }
104
+ if (i === settings.rsi.period) { gain /= settings.rsi.period; loss /= settings.rsi.period }
105
+ if (i >= settings.rsi.period) rsi[i] = loss === 0 ? gain === 0 ? 50 : 100 : 100 - 100 / (1 + gain / loss)
106
+ }
107
+ const point = (values) => points(rows, values)
108
+ const atr = Array(rows.length).fill(null)
109
+ let rangeSum = 0
110
+ for (let i = 1; i < rows.length; i += 1) {
111
+ const range = Math.max(rows[i].h - rows[i].l, Math.abs(rows[i].h - rows[i - 1].c), Math.abs(rows[i].l - rows[i - 1].c))
112
+ if (i <= 14) rangeSum += range
113
+ if (i === 14) atr[i] = rangeSum / 14
114
+ if (i > 14) atr[i] = (atr[i - 1] * 13 + range) / 14
115
+ }
116
+ // UTC sessions, HLC3 weighted by base volume. A partial first day is omitted.
117
+ let day = -1, weighted = 0, volume = 0, complete = false
118
+ const vwap = rows.map((row) => {
119
+ const nextDay = Math.floor(row.t / 86_400_000)
120
+ if (nextDay !== day) {
121
+ // After the first observed day, the exchange can legitimately skip empty
122
+ // midnight bars (e.g. closed HIP-3 markets); the next available bar starts it.
123
+ complete = day >= 0 || row.t % 86_400_000 === 0
124
+ day = nextDay; weighted = 0; volume = 0
125
+ }
126
+ if (!Number.isFinite(row.v) || row.v < 0) return null
127
+ weighted += (row.h + row.l + row.c) / 3 * row.v
128
+ volume += row.v
129
+ return complete && volume > 0 ? weighted / volume : null
130
+ })
131
+ const rawK = rows.map((row, index) => {
132
+ if (index < settings.stochastic.period - 1) return null
133
+ const window = rows.slice(index - settings.stochastic.period + 1, index + 1)
134
+ const low = Math.min(...window.map((bar) => bar.l)), high = Math.max(...window.map((bar) => bar.h))
135
+ return high === low ? null : 100 * (row.c - low) / (high - low)
136
+ })
137
+ const stochK = nullableSma(rawK, settings.stochastic.smoothK)
138
+ const stochD = nullableSma(stochK, settings.stochastic.smoothD)
139
+ const tr = rows.map((row, i) => i === 0 ? null : Math.max(row.h - row.l, Math.abs(row.h - rows[i - 1].c), Math.abs(row.l - rows[i - 1].c)))
140
+ const plus = rows.map((row, i) => { if (!i) return null; const up = row.h - rows[i - 1].h, down = rows[i - 1].l - row.l; return up > down && up > 0 ? up : 0 })
141
+ const minus = rows.map((row, i) => { if (!i) return null; const up = row.h - rows[i - 1].h, down = rows[i - 1].l - row.l; return down > up && down > 0 ? down : 0 })
142
+ const atrDm = rma(tr, settings.adx.period), plusRma = rma(plus, settings.adx.period), minusRma = rma(minus, settings.adx.period)
143
+ const di = (values) => values.map((value, i) => value == null || atrDm[i] == null ? null : atrDm[i] === 0 ? 0 : 100 * value / atrDm[i])
144
+ const plusDI = di(plusRma), minusDI = di(minusRma)
145
+ const dx = plusDI.map((value, i) => value == null || minusDI[i] == null ? null : value + minusDI[i] === 0 ? 0 : 100 * Math.abs(value - minusDI[i]) / (value + minusDI[i]))
146
+ let balance = 0
147
+ const obv = rows.map((row, i) => { if (i) balance += Math.sign(row.c - rows[i - 1].c) * row.v; return balance })
148
+ return {
149
+ stochK: point(stochK), stochD: point(stochD), adx: point(rma(dx, settings.adx.smoothing)), plusDI: point(plusDI), minusDI: point(minusDI), obv: point(obv),
150
+ sma20: point(sma(closes, 20)), basis: point(mean), sma60: point(sma(closes, 60)), ema20: point(ema(closes, 20)), ema50: point(ema(closes, 50)),
151
+ upper: point(mean.map((value, index) => value == null ? null : value + settings.bollinger.multiplier * deviation[index])),
152
+ lower: point(mean.map((value, index) => value == null ? null : value - settings.bollinger.multiplier * deviation[index])),
153
+ rsi14: point(rsi), macd: point(macd), signal: point(signal),
154
+ histogram: point(macd.map((value, index) => value == null || signal[index] == null ? null : value - signal[index])),
155
+ atr14: point(atr), vwap: point(vwap),
156
+ }
157
+ }