Add Hurst Bands indicator with buy/sell signal markers

This commit is contained in:
DiTus
2026-03-03 08:36:26 +01:00
parent 9d7647fde5
commit cf1aca8855
5 changed files with 259 additions and 3 deletions

View File

@ -1507,7 +1507,8 @@
{ type: 'bb', name: 'Bollinger Bands', description: 'Volatility bands' }, { type: 'bb', name: 'Bollinger Bands', description: 'Volatility bands' },
{ type: 'macd', name: 'MACD', description: 'Moving Average Convergence Divergence' }, { type: 'macd', name: 'MACD', description: 'Moving Average Convergence Divergence' },
{ type: 'stoch', name: 'Stochastic', description: 'Stochastic Oscillator' }, { 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' }
]; ];
</script> </script>

View File

@ -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 };

View File

@ -8,6 +8,7 @@ export { RSIIndicator, calculateRSISignal } from './rsi.js';
export { BollingerBandsIndicator, calculateBollingerBandsSignal } from './bb.js'; export { BollingerBandsIndicator, calculateBollingerBandsSignal } from './bb.js';
export { StochasticIndicator, calculateStochSignal } from './stoch.js'; export { StochasticIndicator, calculateStochSignal } from './stoch.js';
export { ATRIndicator, calculateATRSignal } from './atr.js'; export { ATRIndicator, calculateATRSignal } from './atr.js';
export { HurstBandsIndicator, calculateHurstSignal } from './hurst.js';
// Import for registry // Import for registry
import { MAIndicator as MAI, calculateMASignal as CMA } from './moving_average.js'; 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 { BollingerBandsIndicator as BBI, calculateBollingerBandsSignal as CBB } from './bb.js';
import { StochasticIndicator as STOCHI, calculateStochSignal as CST } from './stoch.js'; import { StochasticIndicator as STOCHI, calculateStochSignal as CST } from './stoch.js';
import { ATRIndicator as ATRI, calculateATRSignal as CATR } from './atr.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 // Signal function registry for easy dispatch
export const SignalFunctionRegistry = { export const SignalFunctionRegistry = {
@ -26,7 +28,8 @@ export const SignalFunctionRegistry = {
rsi: CRSI, rsi: CRSI,
bb: CBB, bb: CBB,
stoch: CST, stoch: CST,
atr: CATR atr: CATR,
hurst: CHURST
}; };
// Indicator registry for UI // Indicator registry for UI
@ -37,7 +40,8 @@ export const IndicatorRegistry = {
rsi: RSII, rsi: RSII,
bb: BBI, bb: BBI,
stoch: STOCHI, stoch: STOCHI,
atr: ATRI atr: ATRI,
hurst: HURSTI
}; };
/** /**

View File

@ -163,6 +163,36 @@ function findCrossoverMarkers(indicator, candles, results) {
text: sellShape === 'custom' ? sellCustom : '' 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 { } else {
const ma = result.ma ?? result; const ma = result.ma ?? result;
const prevMa = prevResult.ma ?? prevResult; const prevMa = prevResult.ma ?? prevResult;

View File

@ -143,6 +143,28 @@ function calculateHistoricalCrossovers(activeIndicators, candles) {
lastSignalType = 'buy'; lastSignalType = 'buy';
break; 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 { } else {
// MA-style: check price crossing MA // MA-style: check price crossing MA
const ma = result.ma !== undefined ? result.ma : result; 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.lastSignalType = rsi > overbought ? 'sell' : (rsi < oversold ? 'buy' : null);
indicator.lastSignalTimestamp = lastCandleTime; 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 { } else {
// MA-style: use price vs MA // MA-style: use price vs MA
const ma = lastResult?.ma !== undefined ? lastResult.ma : lastResult; const ma = lastResult?.ma !== undefined ? lastResult.ma : lastResult;