dsh-hyperliquid 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +3 -0
- package/README.md +23 -9
- package/client.js +4446 -588
- package/docs/research-review-2026-10-16.md +46 -0
- package/index.js +2 -1
- package/lib/chart-drawings.d.ts +27 -0
- package/lib/chart-drawings.js +217 -0
- package/lib/indicators.js +7 -2
- package/lib/moving-average.d.ts +21 -0
- package/lib/moving-average.js +133 -0
- package/lib/remote.js +233 -61
- package/lib/review.d.ts +3 -0
- package/lib/review.js +79 -0
- package/lib/risk-settings.js +20 -0
- package/lib/store.js +124 -19
- package/lib/studies.d.ts +13 -0
- package/lib/studies.js +157 -0
- package/lib/tools.js +128 -22
- package/lib/workspace-settings.d.ts +3 -0
- package/lib/workspace-settings.js +12 -0
- package/package.json +8 -3
- package/skills/hyperliquid-trading/SKILL.md +19 -4
package/lib/store.js
CHANGED
|
@@ -5,18 +5,22 @@
|
|
|
5
5
|
* @module lib/store.js
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, rmSync } from 'node:fs'
|
|
8
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, rmSync, unlinkSync } from 'node:fs'
|
|
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
|
-
interval: '15m', bookGrouping: 'full', chartTab: 'chart', tapeTab: 'book', bottomTab: 'positions', indicators: ['volume'],
|
|
16
|
-
priceScale: 'normal', chartRanges: {}, chartLayers: { orders: true, fills: true, drawings: true },
|
|
16
|
+
interval: '15m', bookGrouping: 'full', chartTab: 'chart', tapeTab: 'book', bottomTab: 'positions', indicators: ['volume'], maLines: [],
|
|
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
|
|
@@ -26,6 +30,7 @@ export class HyperStore {
|
|
|
26
30
|
this.liveOrdersFile = join(dataDir, 'live-orders.json')
|
|
27
31
|
this.liveDecisionsFile = join(dataDir, 'live-decisions.json')
|
|
28
32
|
this.chartMarksFile = join(dataDir, 'chart-marks.json')
|
|
33
|
+
this.riskSettingsFile = join(dataDir, 'risk-settings.json')
|
|
29
34
|
this.events = [] // UI event ring
|
|
30
35
|
this.eventSeq = 0
|
|
31
36
|
this.sessions = this.#readJson(this.sessionsFile, { sessions: {} })
|
|
@@ -109,6 +114,14 @@ export class HyperStore {
|
|
|
109
114
|
catch { throw new Error(`会话文件损坏:${file}`) }
|
|
110
115
|
}
|
|
111
116
|
|
|
117
|
+
getRiskSettings() { return this.#readArtifact(this.riskSettingsFile, null) }
|
|
118
|
+
|
|
119
|
+
saveRiskSettings(settings) { this.#writeJson(this.riskSettingsFile, settings) }
|
|
120
|
+
|
|
121
|
+
clearRiskSettings() {
|
|
122
|
+
if (existsSync(this.riskSettingsFile)) unlinkSync(this.riskSettingsFile)
|
|
123
|
+
}
|
|
124
|
+
|
|
112
125
|
#migrateChartMarks() {
|
|
113
126
|
if (!existsSync(this.chartMarksFile)) return
|
|
114
127
|
const legacy = this.#readArtifact(this.chartMarksFile, [])
|
|
@@ -211,29 +224,95 @@ export class HyperStore {
|
|
|
211
224
|
return this.sessions.sessions[sessionId] ?? null
|
|
212
225
|
}
|
|
213
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Drawings synced to every trading session of a coin (TradingView's "sync drawings"):
|
|
229
|
+
* one shared file; each drawing keeps the session that created it as `sessionId`.
|
|
230
|
+
*/
|
|
231
|
+
#shared() {
|
|
232
|
+
if (!this.sharedMarks) {
|
|
233
|
+
const marks = this.#readArtifact(join(this.dataDir, 'shared-chart-marks.json'), [])
|
|
234
|
+
if (!Array.isArray(marks)) throw new Error('共享图表标注文件损坏')
|
|
235
|
+
this.sharedMarks = marks
|
|
236
|
+
}
|
|
237
|
+
return this.sharedMarks
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
#saveShared() { this.#writeJson(join(this.dataDir, 'shared-chart-marks.json'), this.#shared()) }
|
|
241
|
+
|
|
242
|
+
#saveSession(sessionId) { this.#writeJson(this.#sessionFile(sessionId, 'chart-marks.json'), this.#marks(sessionId)) }
|
|
243
|
+
|
|
244
|
+
#notify(mark, sessionId) {
|
|
245
|
+
this.recordEvent('chart.mark', mark.shared
|
|
246
|
+
? { sessionId: null, shared: true, coin: mark.coin, interval: mark.interval, id: mark.id }
|
|
247
|
+
: { sessionId, coin: mark.coin, interval: mark.interval, id: mark.id })
|
|
248
|
+
}
|
|
249
|
+
|
|
214
250
|
listChartMarks(sessionId, coin, interval) {
|
|
215
|
-
|
|
251
|
+
const match = (mark) => mark.coin === coin && (interval == null || mark.interval === interval)
|
|
252
|
+
return [...this.#marks(sessionId).filter(match), ...this.#shared().filter(match)]
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Every drawing that lives in a session file or the shared file (for host-side watchers). */
|
|
256
|
+
allChartMarks() {
|
|
257
|
+
const sessions = Object.keys(this.sessions?.sessions ?? {})
|
|
258
|
+
return [...sessions.flatMap((id) => { try { return this.#marks(id) } catch { return [] } }), ...this.#shared()]
|
|
216
259
|
}
|
|
217
260
|
|
|
218
261
|
addChartMark(mark) {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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} 条标注,请先删除旧线`)
|
|
265
|
+
const marks = mark.shared ? this.#shared() : this.#marks(mark.sessionId)
|
|
222
266
|
if (marks.length >= 300) throw new Error('当前会话最多保留 300 条图表标注,请先删除旧线')
|
|
223
267
|
const saved = { ...mark, id: randomUUID(), createdAt: Date.now() }
|
|
224
268
|
marks.push(saved)
|
|
225
|
-
this.#
|
|
226
|
-
this
|
|
269
|
+
if (mark.shared) this.#saveShared(); else this.#saveSession(mark.sessionId)
|
|
270
|
+
this.#notify(saved, mark.sessionId)
|
|
227
271
|
return saved
|
|
228
272
|
}
|
|
229
273
|
|
|
274
|
+
getChartMark(sessionId, id) {
|
|
275
|
+
const mark = this.#marks(sessionId).find((row) => row.id === id) ?? this.#shared().find((row) => row.id === id)
|
|
276
|
+
if (!mark) throw new Error('图表标注不存在或不属于当前会话')
|
|
277
|
+
return mark
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Patch one drawing; toggling `shared` moves it between the session file and the shared file. */
|
|
281
|
+
updateChartMark(sessionId, id, fields) {
|
|
282
|
+
const own = this.#marks(sessionId), shared = this.#shared()
|
|
283
|
+
const inShared = !own.some((row) => row.id === id)
|
|
284
|
+
const list = inShared ? shared : own
|
|
285
|
+
const index = list.findIndex((row) => row.id === id)
|
|
286
|
+
if (index < 0) throw new Error('图表标注不存在或不属于当前会话')
|
|
287
|
+
const next = { ...list[index], ...fields, updatedAt: Date.now() }
|
|
288
|
+
// Cleared fields (undefined) are removed, so stored and returned drawings stay lossless JSON.
|
|
289
|
+
for (const [key, value] of Object.entries(next)) if (value === undefined) delete next[key]
|
|
290
|
+
if (!next.shared) delete next.shared
|
|
291
|
+
const moving = Boolean(next.shared) !== inShared
|
|
292
|
+
if (moving) {
|
|
293
|
+
list.splice(index, 1)
|
|
294
|
+
if (next.shared) shared.push(next); else own.push({ ...next, sessionId })
|
|
295
|
+
this.#saveShared(); this.#saveSession(sessionId)
|
|
296
|
+
// Both the old and the new owners must reload.
|
|
297
|
+
this.recordEvent('chart.mark', { sessionId: null, shared: true, coin: next.coin, interval: next.interval, id })
|
|
298
|
+
this.recordEvent('chart.mark', { sessionId, coin: next.coin, interval: next.interval, id })
|
|
299
|
+
return next.shared ? next : { ...next, sessionId }
|
|
300
|
+
}
|
|
301
|
+
list[index] = next
|
|
302
|
+
if (inShared) this.#saveShared(); else this.#saveSession(sessionId)
|
|
303
|
+
this.#notify(next, sessionId)
|
|
304
|
+
return next
|
|
305
|
+
}
|
|
306
|
+
|
|
230
307
|
removeChartMark(sessionId, id) {
|
|
231
|
-
const
|
|
232
|
-
const
|
|
308
|
+
const own = this.#marks(sessionId)
|
|
309
|
+
const inShared = !own.some((row) => row.id === id)
|
|
310
|
+
const list = inShared ? this.#shared() : own
|
|
311
|
+
const mark = list.find((row) => row.id === id)
|
|
233
312
|
if (!mark) throw new Error('图表标注不存在或不属于当前会话')
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
this
|
|
313
|
+
list.splice(list.indexOf(mark), 1)
|
|
314
|
+
if (inShared) this.#saveShared(); else this.#saveSession(sessionId)
|
|
315
|
+
this.#notify(mark, sessionId)
|
|
237
316
|
return mark
|
|
238
317
|
}
|
|
239
318
|
|
|
@@ -254,16 +333,39 @@ export class HyperStore {
|
|
|
254
333
|
return JSON.parse(JSON.stringify(this.paper))
|
|
255
334
|
}
|
|
256
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
|
+
|
|
257
357
|
/**
|
|
258
358
|
* Fill a paper order against the given mid with a simple slippage model.
|
|
259
359
|
* @returns the fill record
|
|
260
360
|
*/
|
|
261
|
-
paperFill({ coin, isBuy, sz, px, time = Date.now(), reason = 'order' }) {
|
|
361
|
+
paperFill({ coin, isBuy, sz, px, time = Date.now(), reason = 'order', sessionId = null, feeBps = 0, referencePx = null, slippageBps = null }) {
|
|
262
362
|
const szNum = Number(sz)
|
|
263
363
|
const pxNum = Number(px)
|
|
264
364
|
if (!Number.isFinite(szNum) || szNum <= 0) throw new Error('paper: 无效数量')
|
|
265
365
|
if (!Number.isFinite(pxNum) || pxNum <= 0) throw new Error('paper: 无效价格')
|
|
266
366
|
const notional = szNum * pxNum
|
|
367
|
+
if (!Number.isFinite(feeBps) || feeBps < 0 || feeBps > 100) throw Error('paper: 无效手续费')
|
|
368
|
+
const fee = notional * feeBps / 10_000
|
|
267
369
|
const pos = this.paper.positions[coin] ?? { size: 0, entryPx: pxNum, leverage: this.paper.leverageByCoin?.[coin] ?? 1 }
|
|
268
370
|
const signed = isBuy ? szNum : -szNum
|
|
269
371
|
const prevSize = pos.size
|
|
@@ -286,10 +388,13 @@ export class HyperStore {
|
|
|
286
388
|
pos.size = newSize
|
|
287
389
|
this.paper.positions[coin] = pos
|
|
288
390
|
}
|
|
289
|
-
this.paper.usd += realized
|
|
290
|
-
|
|
391
|
+
this.paper.usd += realized - fee
|
|
392
|
+
// sessionId: the trading conversation whose decision produced this fill.
|
|
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 }
|
|
291
396
|
this.paper.fills.unshift(fill)
|
|
292
|
-
if (this.paper.fills.length >
|
|
397
|
+
if (this.paper.fills.length > 5000) this.paper.fills.length = 5000
|
|
293
398
|
this.#writeJson(this.paperFile, this.paper)
|
|
294
399
|
this.recordEvent('paper.fill', fill)
|
|
295
400
|
return fill
|
|
@@ -298,7 +403,7 @@ export class HyperStore {
|
|
|
298
403
|
paperOrderRecord(order) {
|
|
299
404
|
const row = { ...order, time: Date.now(), status: 'filled' }
|
|
300
405
|
this.paper.orders.unshift(row)
|
|
301
|
-
if (this.paper.orders.length >
|
|
406
|
+
if (this.paper.orders.length > 5000) this.paper.orders.length = 5000
|
|
302
407
|
this.#writeJson(this.paperFile, this.paper)
|
|
303
408
|
this.recordEvent('paper.order', row)
|
|
304
409
|
}
|
package/lib/studies.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface IndicatorSettings {
|
|
2
|
+
rsi: { period: number; lower: number; upper: number }
|
|
3
|
+
macd: { fast: number; slow: number; signal: number }
|
|
4
|
+
bollinger: { period: number; multiplier: number }
|
|
5
|
+
stochastic: { period: number; smoothK: number; smoothD: number; lower: number; upper: number }
|
|
6
|
+
adx: { period: number; smoothing: number }
|
|
7
|
+
}
|
|
8
|
+
export type StudyKey = 'sma20' | 'sma60' | 'ema20' | 'ema50' | 'basis' | 'upper' | 'lower' | 'rsi14' | 'macd' | 'signal' | 'histogram' | 'atr14' | 'vwap' | 'stochK' | 'stochD' | 'adx' | 'plusDI' | 'minusDI' | 'obv'
|
|
9
|
+
export declare const INDICATOR_IDS: string[]
|
|
10
|
+
export declare const DEFAULT_INDICATOR_SETTINGS: IndicatorSettings
|
|
11
|
+
export declare function normalizeIndicatorSettings(input?: unknown): IndicatorSettings
|
|
12
|
+
export declare function computeStudies(candles: readonly {t: number; o: number; h: number; l: number; c: number; v: number}[], input?: IndicatorSettings): Record<StudyKey, Array<{ time: number; value: number }>>
|
|
13
|
+
export declare function studyDefinitions(input?: IndicatorSettings): Array<[string, StudyKey, string]>
|
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
|
+
}
|