dsh-hyperliquid 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +20 -9
- package/client.js +2302 -628
- package/docs/research-review-2026-10-16.md +46 -0
- package/index.js +43 -19
- package/lib/analysis.js +183 -0
- package/lib/chart-drawings.d.ts +7 -2
- package/lib/chart-drawings.js +67 -0
- package/lib/hl.js +6 -0
- package/lib/journal.js +155 -0
- package/lib/prompt.js +92 -0
- package/lib/remote.js +410 -80
- package/lib/review.d.ts +3 -0
- package/lib/review.js +79 -0
- package/lib/risk-settings.js +2 -1
- package/lib/scan.js +91 -0
- package/lib/signing.js +58 -0
- package/lib/skill.js +2 -2
- package/lib/store.js +68 -8
- package/lib/studies.d.ts +13 -0
- package/lib/studies.js +157 -0
- package/lib/tools.js +191 -35
- package/lib/turn-origin.js +46 -0
- package/lib/wake.js +85 -0
- package/lib/workspace-settings.d.ts +3 -0
- package/lib/workspace-settings.js +12 -0
- package/package.json +8 -4
- package/skills/hyperliquid-trading/SKILL.md +106 -27
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/risk-settings.js
CHANGED
|
@@ -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) =>
|
|
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: '
|
|
14
|
-
whenToUse: '讨论当前交易台、Hyperliquid
|
|
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
|
-
|
|
259
|
-
|
|
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
|
|
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,
|
|
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 >
|
|
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 >
|
|
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
|
}
|
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]>
|
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
|
+
}
|