diff --git a/src/api/dashboard/static/index.html b/src/api/dashboard/static/index.html index 4e7d19a..35c9c79 100644 --- a/src/api/dashboard/static/index.html +++ b/src/api/dashboard/static/index.html @@ -1507,7 +1507,8 @@ { type: 'bb', name: 'Bollinger Bands', description: 'Volatility bands' }, { type: 'macd', name: 'MACD', description: 'Moving Average Convergence Divergence' }, { type: 'stoch', name: 'Stochastic', description: 'Stochastic Oscillator' }, - { type: 'atr', name: 'ATR', description: 'Average True Range' } + { type: 'atr', name: 'ATR', description: 'Average True Range' }, + { type: 'hurst', name: 'Hurst Bands', description: 'Cyclic price channels' } ]; diff --git a/src/api/dashboard/static/js/indicators/hurst.js b/src/api/dashboard/static/js/indicators/hurst.js new file mode 100644 index 0000000..2e511db --- /dev/null +++ b/src/api/dashboard/static/js/indicators/hurst.js @@ -0,0 +1,184 @@ +// Self-contained Hurst Bands indicator +// Based on J.M. Hurst's cyclic price channel theory +// Using RMA + ATR displacement method + +const SIGNAL_TYPES = { + BUY: 'buy', + SELL: 'sell', + HOLD: 'hold' +}; + +const SIGNAL_COLORS = { + buy: '#26a69a', + hold: '#787b86', + sell: '#ef5350' +}; + +class BaseIndicator { + constructor(config) { + this.id = config.id; + this.type = config.type; + this.name = config.name; + this.params = config.params || {}; + this.timeframe = config.timeframe || '1m'; + this.series = []; + this.visible = config.visible !== false; + this.cachedResults = null; + this.cachedMeta = null; + this.lastSignalTimestamp = null; + this.lastSignalType = null; + } +} + +// Calculate ATR (Average True Range) +function calculateATR(candles, period) { + const atr = new Array(candles.length).fill(null); + + for (let i = 1; i < candles.length; i++) { + const high = candles[i].high; + const low = candles[i].low; + const prevClose = candles[i - 1].close; + + const tr = Math.max( + high - low, + Math.abs(high - prevClose), + Math.abs(low - prevClose) + ); + + if (i >= period) { + let sum = 0; + for (let j = 0; j < period; j++) { + const idx = i - j; + const h = candles[idx].high; + const l = candles[idx].low; + const pc = candles[idx - 1]?.close || h; + sum += Math.max(h - l, Math.abs(h - pc), Math.abs(l - pc)); + } + atr[i] = sum / period; + } else { + let sum = 0; + for (let j = 1; j <= i; j++) { + const h = candles[j].high; + const l = candles[j].low; + const pc = candles[j - 1].close; + sum += Math.max(h - l, Math.abs(h - pc), Math.abs(l - pc)); + } + atr[i] = sum / (i + 1); + } + } + + return atr; +} + +// Calculate RMA (Rolling Moving Average - Wilder's method) +function calculateRMA(values, period) { + const rma = new Array(values.length).fill(null); + + let sum = 0; + for (let i = 0; i < period && i < values.length; i++) { + sum += values[i]; + if (i === period - 1) { + rma[i] = sum / period; + } + } + + const alpha = 1 / period; + for (let i = period; i < values.length; i++) { + if (rma[i - 1] !== null) { + rma[i] = rma[i - 1] + alpha * (values[i] - rma[i - 1]); + } + } + + 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; + + if (!upper || !lower || prevClose === undefined) { + return null; + } + + if (prevClose > lower && close < lower) { + return { + type: 'buy', + strength: 75, + value: close, + reasoning: `Price crossed down below lower Hurst Band (${lower.toFixed(2)}), expect bounce` + }; + } + + if (prevClose > upper && close < upper) { + return { + type: 'sell', + strength: 75, + value: close, + reasoning: `Price crossed down below upper Hurst Band (${upper.toFixed(2)}), expect reversal` + }; + } + + return null; +} + +export class HurstBandsIndicator extends BaseIndicator { + constructor(config) { + super(config); + this.lastSignalTimestamp = null; + this.lastSignalType = null; + } + + calculate(candles) { + const mcl = this.params.period || 30; + const mcm = this.params.multiplier || 1.8; + const results = new Array(candles.length).fill(null); + + const closes = candles.map(c => c.close); + + const ma_mcl = calculateRMA(closes, mcl); + const atr = calculateATR(candles, mcl); + + const mcl_2 = Math.floor(mcl / 2); + + for (let i = mcl_2; i < candles.length; i++) { + const ma_val = ma_mcl[i]; + const atr_val = atr[i]; + + if (ma_val === null || atr_val === null) continue; + + const displacedIdx = i - mcl_2; + const displacedRMA = displacedIdx >= 0 ? ma_mcl[displacedIdx] : null; + + if (displacedRMA === null) continue; + + const mcm_off = mcm * atr_val; + + results[i] = { + upper: displacedRMA + mcm_off, + lower: displacedRMA - mcm_off + }; + } + + return results; + } + + getMetadata() { + return { + name: 'Hurst Bands', + description: 'Cyclic price channels based on Hurst theory', + inputs: [ + { name: 'period', label: 'Period (mcl)', 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: '#ff6b6b', title: 'Upper' }, + { id: 'lower', color: '#4caf50', title: 'Lower' } + ], + displayMode: 'overlay' + }; + } +} + +export { calculateHurstSignal }; diff --git a/src/api/dashboard/static/js/indicators/index.js b/src/api/dashboard/static/js/indicators/index.js index b4a950d..4b88ce9 100644 --- a/src/api/dashboard/static/js/indicators/index.js +++ b/src/api/dashboard/static/js/indicators/index.js @@ -8,6 +8,7 @@ export { RSIIndicator, calculateRSISignal } from './rsi.js'; export { BollingerBandsIndicator, calculateBollingerBandsSignal } from './bb.js'; export { StochasticIndicator, calculateStochSignal } from './stoch.js'; export { ATRIndicator, calculateATRSignal } from './atr.js'; +export { HurstBandsIndicator, calculateHurstSignal } from './hurst.js'; // Import for registry import { MAIndicator as MAI, calculateMASignal as CMA } from './moving_average.js'; @@ -17,6 +18,7 @@ import { RSIIndicator as RSII, calculateRSISignal as CRSI } from './rsi.js'; import { BollingerBandsIndicator as BBI, calculateBollingerBandsSignal as CBB } from './bb.js'; import { StochasticIndicator as STOCHI, calculateStochSignal as CST } from './stoch.js'; import { ATRIndicator as ATRI, calculateATRSignal as CATR } from './atr.js'; +import { HurstBandsIndicator as HURSTI, calculateHurstSignal as CHURST } from './hurst.js'; // Signal function registry for easy dispatch export const SignalFunctionRegistry = { @@ -26,7 +28,8 @@ export const SignalFunctionRegistry = { rsi: CRSI, bb: CBB, stoch: CST, - atr: CATR + atr: CATR, + hurst: CHURST }; // Indicator registry for UI @@ -37,7 +40,8 @@ export const IndicatorRegistry = { rsi: RSII, bb: BBI, stoch: STOCHI, - atr: ATRI + atr: ATRI, + hurst: HURSTI }; /** diff --git a/src/api/dashboard/static/js/ui/signal-markers.js b/src/api/dashboard/static/js/ui/signal-markers.js index e0430ce..e80191d 100644 --- a/src/api/dashboard/static/js/ui/signal-markers.js +++ b/src/api/dashboard/static/js/ui/signal-markers.js @@ -163,6 +163,36 @@ function findCrossoverMarkers(indicator, candles, results) { text: sellShape === 'custom' ? sellCustom : '' }); } + } else if (indicatorType === 'hurst') { + const upper = result.upper; + const lower = result.lower; + const prevUpper = prevResult?.upper; + const prevLower = prevResult?.lower; + + if (upper === undefined || lower === undefined || + prevUpper === undefined || prevLower === undefined) continue; + + // BUY: price crosses down below lower band (was above, now below) + if (prevCandle.close > prevLower && candle.close < lower) { + markers.push({ + time: candle.time, + position: 'belowBar', + color: buyColor, + shape: buyShape === 'custom' ? '' : buyShape, + text: buyShape === 'custom' ? buyCustom : '' + }); + } + + // SELL: price crosses down below upper band (was above, now below) + if (prevCandle.close > prevUpper && candle.close < upper) { + markers.push({ + time: candle.time, + position: 'aboveBar', + color: sellColor, + shape: sellShape === 'custom' ? '' : sellShape, + text: sellShape === 'custom' ? sellCustom : '' + }); + } } else { const ma = result.ma ?? result; const prevMa = prevResult.ma ?? prevResult; diff --git a/src/api/dashboard/static/js/ui/signals-calculator.js b/src/api/dashboard/static/js/ui/signals-calculator.js index b340807..ad97c50 100644 --- a/src/api/dashboard/static/js/ui/signals-calculator.js +++ b/src/api/dashboard/static/js/ui/signals-calculator.js @@ -143,6 +143,28 @@ function calculateHistoricalCrossovers(activeIndicators, candles) { lastSignalType = 'buy'; break; } + } else if (indicatorType === 'hurst') { + // Hurst Bands: check price crossing bands + const upper = result.upper; + const lower = result.lower; + const prevUpper = prevResult.upper; + const prevLower = prevResult.lower; + + if (upper === undefined || lower === undefined || + prevUpper === undefined || prevLower === undefined) continue; + + // BUY: price crossed down below lower band + if (prevCandle.close > prevLower && candle.close < lower) { + lastCrossoverTimestamp = candle.time; + lastSignalType = 'buy'; + break; + } + // SELL: price crossed down below upper band + if (prevCandle.close > prevUpper && candle.close < upper) { + lastCrossoverTimestamp = candle.time; + lastSignalType = 'sell'; + break; + } } else { // MA-style: check price crossing MA const ma = result.ma !== undefined ? result.ma : result; @@ -188,6 +210,21 @@ function calculateHistoricalCrossovers(activeIndicators, candles) { indicator.lastSignalType = rsi > overbought ? 'sell' : (rsi < oversold ? 'buy' : null); indicator.lastSignalTimestamp = lastCandleTime; } + } else if (indicatorType === 'hurst') { + // Hurst Bands: use price vs bands + const upper = lastResult?.upper; + const lower = lastResult?.lower; + const currentPrice = candles[candles.length - 1]?.close; + if (upper !== undefined && lower !== undefined && currentPrice !== undefined) { + if (currentPrice < lower) { + indicator.lastSignalType = 'buy'; + } else if (currentPrice > upper) { + indicator.lastSignalType = 'sell'; + } else { + indicator.lastSignalType = null; + } + indicator.lastSignalTimestamp = lastCandleTime; + } } else { // MA-style: use price vs MA const ma = lastResult?.ma !== undefined ? lastResult.ma : lastResult;