// Self-contained Hurst Bands indicator // Based on J.M. Hurst's cyclic price channel theory // Using RMA + ATR displacement method import { INTERVALS } from '../core/constants.js'; const SIGNAL_TYPES = { BUY: 'buy', SELL: 'sell' }; const SIGNAL_COLORS = { buy: '#9e9e9e', sell: '#9e9e9e' }; class BaseIndicator { constructor(config) { this.config = config; this.id = config.id; this.type = config.type; this.name = config.name; this.params = config.params || {}; this.timeframe = config.timeframe || 'chart'; this.series = []; this.visible = config.visible !== false; if (config.cachedResults === undefined) config.cachedResults = null; if (config.cachedMeta === undefined) config.cachedMeta = null; if (config.cachedTimeframe === undefined) config.cachedTimeframe = null; if (config.isFetching === undefined) config.isFetching = false; if (config.lastProcessedTime === undefined) config.lastProcessedTime = 0; } get cachedResults() { return this.config.cachedResults; } set cachedResults(v) { this.config.cachedResults = v; } get cachedMeta() { return this.config.cachedMeta; } set cachedMeta(v) { this.config.cachedMeta = v; } get cachedTimeframe() { return this.config.cachedTimeframe; } set cachedTimeframe(v) { this.config.cachedTimeframe = v; } get isFetching() { return this.config.isFetching; } set isFetching(v) { this.config.isFetching = v; } get lastProcessedTime() { return this.config.lastProcessedTime; } set lastProcessedTime(v) { this.config.lastProcessedTime = v; } } // Optimized RMA that can start from a previous state function calculateRMAIncremental(sourceValue, prevRMA, length) { if (prevRMA === null || isNaN(prevRMA)) return sourceValue; const alpha = 1 / length; return alpha * sourceValue + (1 - alpha) * prevRMA; } // Calculate RMA for a full array with stable initialization function calculateRMA(sourceArray, length) { const rma = new Array(sourceArray.length).fill(null); let sum = 0; const alpha = 1 / length; const smaLength = Math.round(length); for (let i = 0; i < sourceArray.length; i++) { if (i < smaLength - 1) { sum += sourceArray[i]; } else if (i === smaLength - 1) { sum += sourceArray[i]; rma[i] = sum / smaLength; } else { const prevRMA = rma[i - 1]; rma[i] = (prevRMA === null || isNaN(prevRMA)) ? sourceArray[i] : alpha * sourceArray[i] + (1 - alpha) * prevRMA; } } return rma; } function calculateHurstSignal(indicator, lastCandle, prevCandle, values, prevValues) { const close = lastCandle.close; const prevClose = prevCandle?.close; const upper = values?.upper; const lower = values?.lower; const prevUpper = prevValues?.upper; const prevLower = prevValues?.lower; if (close === undefined || prevClose === undefined || !upper || !lower || !prevUpper || !prevLower) { return null; } // BUY: Price crosses DOWN through lower Hurst Band (dip entry) if (prevClose > prevLower && close <= lower) { return { type: 'buy', strength: 80, value: close, reasoning: `Price crossed DOWN through lower Hurst Band` }; } // SELL: Price crosses DOWN through upper Hurst Band (reversal entry) if (prevClose > prevUpper && close <= upper) { return { type: 'sell', strength: 80, value: close, reasoning: `Price crossed DOWN through upper Hurst Band` }; } return null; } function getEffectiveTimeframe(params) { return params.timeframe === 'chart' ? window.dashboard?.currentInterval || '1m' : params.timeframe; } function intervalToSeconds(interval) { const amount = parseInt(interval); const unit = interval.replace(/[0-9]/g, ''); switch (unit) { case 'm': return amount * 60; case 'h': return amount * 3600; case 'd': return amount * 86400; case 'w': return amount * 604800; case 'M': return amount * 2592000; default: return 60; } } async function getCandlesForTimeframe(tf, startTime, endTime) { const url = `${window.APP_CONFIG.API_BASE_URL}/candles?symbol=BTC&interval=${tf}&start=${startTime.toISOString()}&end=${endTime.toISOString()}&limit=5000`; const response = await fetch(url); if (!response.ok) { console.error(`Failed to fetch candles for ${tf}:`, response.status, response.statusText); return []; } const data = await response.json(); // API returns newest first (desc), but indicators need oldest first (asc) // Also convert time to numeric seconds to match targetCandles return (data.candles || []).reverse().map(c => ({ ...c, time: Math.floor(new Date(c.time).getTime() / 1000) })); } /** * Robust forward filling for MTF data. * @param {Array} results - MTF results (e.g. 5m) * @param {Array} targetCandles - Chart candles (e.g. 1m) */ function forwardFillResults(results, targetCandles) { if (!results || results.length === 0) { return new Array(targetCandles.length).fill(null); } const filled = new Array(targetCandles.length).fill(null); let resIdx = 0; for (let i = 0; i < targetCandles.length; i++) { const targetTime = targetCandles[i].time; // Advance result index while next result time is <= target time while (resIdx < results.length - 1 && results[resIdx + 1].time <= targetTime) { resIdx++; } // If the current result is valid for this target time, use it // (result time must be <= target time) if (results[resIdx] && results[resIdx].time <= targetTime) { filled[i] = results[resIdx]; } } return filled; } export class HurstBandsIndicator extends BaseIndicator { constructor(config) { super(config); if (!this.params.timeframe) this.params.timeframe = 'chart'; if (!this.params.markerBuyShape) this.params.markerBuyShape = 'custom'; if (!this.params.markerSellShape) this.params.markerSellShape = 'custom'; if (!this.params.markerBuyColor) this.params.markerBuyColor = '#9e9e9e'; if (!this.params.markerSellColor) this.params.markerSellColor = '#9e9e9e'; if (!this.params.markerBuyCustom) this.params.markerBuyCustom = '▲'; if (!this.params.markerSellCustom) this.params.markerSellCustom = '▼'; } calculate(candles) { const effectiveTf = getEffectiveTimeframe(this.params); const lastCandle = candles[candles.length - 1]; // Case 1: Different timeframe (MTF) if (effectiveTf !== window.dashboard?.currentInterval && this.params.timeframe !== 'chart') { // If we have cached results, try to forward fill them to match the current candle count if (this.cachedResults && this.cachedTimeframe === effectiveTf) { // If results are stale (last result time is behind last candle time), trigger background fetch const lastResult = this.cachedResults[this.cachedResults.length - 1]; const needsFetch = !this.isFetching && (!lastResult || lastCandle.time > lastResult.time + (intervalToSeconds(effectiveTf) / 2)); if (needsFetch) { this._fetchAndCalculateMtf(effectiveTf, candles); } // If length matches exactly and params haven't changed, return if (this.cachedResults.length === candles.length && !this.shouldRecalculate()) { return this.cachedResults; } // If length differs (e.g. new 1m candle but 5m not fetched yet), forward fill const filled = forwardFillResults(this.cachedResults.filter(r => r !== null), candles); this.cachedResults = filled; return filled; } // Initial fetch if (!this.isFetching) { this._fetchAndCalculateMtf(effectiveTf, candles); } return new Array(candles.length).fill(null); } // Case 2: Same timeframe as chart (Incremental or Full) // Check if we can do incremental update if (this.cachedResults && this.cachedResults.length > 0 && this.cachedTimeframe === effectiveTf && !this.shouldRecalculate() && candles.length >= this.cachedResults.length && candles[this.cachedResults.length - 1].time === this.cachedResults[this.cachedResults.length - 1].time) { // Only calculate new candles if (candles.length > this.cachedResults.length) { const newResults = this._calculateIncremental(candles, this.cachedResults); this.cachedResults = newResults; } return this.cachedResults; } // Full calculation const results = this._calculateCore(candles); this.cachedTimeframe = effectiveTf; this.updateCachedMeta(this.params); this.cachedResults = results; return results; } _calculateCore(candles) { const mcl_t = this.params.period || 30; const mcm = this.params.multiplier || 1.8; const mcl = mcl_t / 2; const mcl_2 = Math.round(mcl / 2); const results = new Array(candles.length).fill(null); const closes = candles.map(c => c.close); // True Range for ATR const trArray = candles.map((d, i) => { const prevClose = i > 0 ? candles[i - 1].close : null; if (prevClose === null || isNaN(prevClose)) return d.high - d.low; return Math.max(d.high - d.low, Math.abs(d.high - prevClose), Math.abs(d.low - prevClose)); }); const ma_mcl = calculateRMA(closes, mcl); const atr = calculateRMA(trArray, mcl); for (let i = 0; i < candles.length; i++) { const mcm_off = mcm * (atr[i] || 0); const historicalIndex = i - mcl_2; const historical_ma = historicalIndex >= 0 ? ma_mcl[historicalIndex] : null; const centerLine = (historical_ma === null || isNaN(historical_ma)) ? closes[i] : historical_ma; results[i] = { time: candles[i].time, upper: centerLine + mcm_off, lower: centerLine - mcm_off, ma: ma_mcl[i], // Store intermediate state for incremental updates atr: atr[i] }; } return results; } _calculateIncremental(candles, oldResults) { const mcl_t = this.params.period || 30; const mcm = this.params.multiplier || 1.8; const mcl = mcl_t / 2; const mcl_2 = Math.round(mcl / 2); const results = [...oldResults]; const startIndex = oldResults.length; for (let i = startIndex; i < candles.length; i++) { const close = candles[i].close; const prevClose = candles[i-1].close; const tr = Math.max(candles[i].high - candles[i].low, Math.abs(candles[i].high - prevClose), Math.abs(candles[i].low - prevClose)); const prevMA = results[i-1]?.ma; const prevATR = results[i-1]?.atr; const currentMA = calculateRMAIncremental(close, prevMA, mcl); const currentATR = calculateRMAIncremental(tr, prevATR, mcl); // For displaced center line, we still need the MA from i - mcl_2 // Since i >= oldResults.length, i - mcl_2 might be in the old results let historical_ma = null; const historicalIndex = i - mcl_2; if (historicalIndex >= 0) { historical_ma = historicalIndex < startIndex ? results[historicalIndex].ma : null; // In this simple incremental, we don't look ahead } const centerLine = (historical_ma === null || isNaN(historical_ma)) ? close : historical_ma; const mcm_off = mcm * (currentATR || 0); results[i] = { time: candles[i].time, upper: centerLine + mcm_off, lower: centerLine - mcm_off, ma: currentMA, atr: currentATR }; } return results; } async _fetchAndCalculateMtf(effectiveTf, targetCandles) { this.isFetching = true; try { console.log(`[Hurst] Fetching MTF data for ${effectiveTf}...`); const chartData = window.dashboard?.allData?.get(window.dashboard?.currentInterval) || targetCandles; if (!chartData || chartData.length === 0) { console.warn('[Hurst] No chart data available for timeframe fetch'); this.isFetching = false; return; } // Calculate warmup needed (period + half width) const mcl_t = this.params.period || 30; const warmupBars = mcl_t * 2; // Extra buffer const tfSeconds = intervalToSeconds(effectiveTf); const warmupOffsetSeconds = warmupBars * tfSeconds; // Candles endpoint expects ISO strings or timestamps. // chartData[0].time is the earliest candle on chart. const startTime = new Date((chartData[0].time - warmupOffsetSeconds) * 1000); const endTime = new Date(chartData[chartData.length - 1].time * 1000); const tfCandles = await getCandlesForTimeframe(effectiveTf, startTime, endTime); if (tfCandles.length === 0) { console.warn(`[Hurst] No candles fetched for ${effectiveTf}`); this.isFetching = false; return; } console.log(`[Hurst] Fetched ${tfCandles.length} candles for ${effectiveTf}. Calculating...`); const tfResults = this._calculateCore(tfCandles); const finalResults = forwardFillResults(tfResults, targetCandles); // Persist results on the config object this.cachedResults = finalResults; this.cachedTimeframe = effectiveTf; this.updateCachedMeta(this.params); console.log(`[Hurst] MTF calculation complete for ${effectiveTf}. Triggering redraw.`); // Trigger a redraw of the dashboard to show the new data if (window.drawIndicatorsOnChart) { window.drawIndicatorsOnChart(); } } catch (err) { console.error('[Hurst] Error in _fetchAndCalculateMtf:', err); } finally { this.isFetching = false; } } getMetadata() { return { name: 'Hurst Bands', description: 'Cyclic price channels based on Hurst theory', inputs: [ { name: 'timeframe', label: 'Timeframe', type: 'select', default: 'chart', options: ['chart', ...INTERVALS], labels: { chart: '(Main Chart)' } }, { name: 'period', label: 'Hurst Cycle Length (mcl_t)', type: 'number', default: 30, min: 5, max: 200 }, { name: 'multiplier', label: 'Multiplier (mcm)', type: 'number', default: 1.8, min: 0.5, max: 10, step: 0.1 } ], plots: [ { id: 'upper', color: '#808080', title: 'Upper', lineWidth: 1 }, { id: 'lower', color: '#808080', title: 'Lower', lineWidth: 1 } ], bands: [ { topId: 'upper', bottomId: 'lower', color: 'rgba(128, 128, 128, 0.05)' } ], displayMode: 'overlay' }; } shouldRecalculate() { const effectiveTf = getEffectiveTimeframe(this.params); return this.cachedTimeframe !== effectiveTf || (this.cachedMeta && (this.cachedMeta.period !== this.params.period || this.cachedMeta.multiplier !== this.params.multiplier)); } updateCachedMeta(params) { this.cachedMeta = { period: params.period, multiplier: params.multiplier }; } } export { calculateHurstSignal };