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;