dual hurst + removed labels

This commit is contained in:
2025-07-14 23:47:00 +02:00
parent 80e3875abe
commit e88f836f9d
3 changed files with 191 additions and 99 deletions

File diff suppressed because one or more lines are too long

View File

@ -1,22 +1,50 @@
/**
* Indicator Definition Object for Hurst Bands.
* Indicator Definition Object for Hurst Bands (Multi-Timeframe).
* This object is used by the indicator manager to create and control the indicator.
* It defines the parameters and the calculation functions.
*/
const HURST_INDICATOR = {
name: 'Hurst',
label: 'Hurst Bands',
label: 'Hurst Bands (Multi-TF)',
params: [
{ name: 'cycle', type: 'number', defaultValue: 30, min: 2 },
{ name: 'atr_mult', type: 'number', defaultValue: 1.8, min: 0.1, step: 0.1 },
{ name: 'timeframe_mult', type: 'number', defaultValue: 5, min: 2, step: 1 },
],
// This indicator returns multiple lines, so the manager will need to handle an object of arrays.
// The output is { topBand, bottomBand, topBand_h, bottomBand_h }
calculateFull: calculateFullHurst,
createRealtime: createRealtimeHurstCalculator,
};
// --- Helper Functions (private to this file) ---
/**
* Aggregates candle data into a higher timeframe.
* @param {Array<Object>} data - The original candle data.
* @param {number} multiplier - The timeframe multiplier (e.g., 5 for 5-minute candles from 1-minute data).
* @returns {Array<Object>} A new array of aggregated candle objects.
*/
function _aggregateCandles(data, multiplier) {
if (multiplier <= 1) return data;
const aggregatedData = [];
for (let i = 0; i < data.length; i += multiplier) {
const chunk = data.slice(i, i + multiplier);
if (chunk.length > 0) {
const newCandle = {
open: chunk[0].open,
high: Math.max(...chunk.map(c => c.high)),
low: Math.min(...chunk.map(c => c.low)),
close: chunk[chunk.length - 1].close,
// The timestamp of the new candle corresponds to the end of the period.
time: chunk[chunk.length - 1].time,
};
aggregatedData.push(newCandle);
}
}
return aggregatedData;
}
/**
* Calculates RMA (Relative Moving Average), a type of EMA.
* @param {number[]} series - An array of numbers.
@ -24,18 +52,14 @@ const HURST_INDICATOR = {
* @returns {number[]} The calculated RMA series.
*/
function _calculateRMA(series, period) {
if (series.length < period) return [];
const alpha = 1 / period;
let rma = [];
if (series.length < period) return rma;
// Initial SMA
let sum = 0;
for (let i = 0; i < period; i++) {
sum += series[i];
}
rma.push(sum / period);
// Subsequent RMAs
for (let i = period; i < series.length; i++) {
const val = alpha * series[i] + (1 - alpha) * rma[rma.length - 1];
rma.push(val);
@ -51,10 +75,7 @@ function _calculateRMA(series, period) {
*/
function _calculateATR(data, period) {
if (data.length < period) return [];
let tr_series = [];
tr_series.push(data[0].high - data[0].low); // First TR is just High - Low
let tr_series = [data[0].high - data[0].low];
for (let i = 1; i < data.length; i++) {
const h = data[i].high;
const l = data[i].low;
@ -62,78 +83,112 @@ function _calculateATR(data, period) {
const tr = Math.max(h - l, Math.abs(h - prev_c), Math.abs(l - prev_c));
tr_series.push(tr);
}
// Smooth the True Range series with RMA to get ATR
return _calculateRMA(tr_series, period);
}
// --- Main Calculation Functions ---
/**
* Calculates the Hurst Bands for an entire dataset.
* @param {Array<Object>} data - An array of candle objects.
* @param {Object} params - An object with { cycle, atr_mult }.
* A generic function to calculate a single set of Hurst Bands.
* This is the core calculation logic.
* @param {Array<Object>} data - An array of candle objects for a specific timeframe.
* @param {number} cycle - The cycle length for this calculation.
* @param {number} atr_mult - The ATR multiplier for this calculation.
* @returns {Object} An object containing two arrays: { topBand: [...], bottomBand: [...] }.
*/
function calculateFullHurst(data, params) {
const { cycle, atr_mult } = params;
function _calculateSingleBandSet(data, cycle, atr_mult) {
const mcl = Math.floor(cycle / 2);
const mcl_2 = Math.floor(mcl / 2);
// Ensure there's enough data for all calculations, including the lookback.
if (data.length < cycle + mcl_2) {
return { topBand: [], bottomBand: [] };
}
const closePrices = data.map(d => d.close);
// 1. Calculate RMA of close prices
const ma_mcl_full = _calculateRMA(closePrices, mcl);
// 2. Calculate ATR
const atr_full = _calculateATR(data, mcl);
const topBand = [];
const bottomBand = [];
// Loop through the data to construct the bands.
// We start the loop where the first valid calculation can occur.
const startIndex = mcl - 1 + mcl_2;
for (let i = startIndex; i < data.length; i++) {
// Align indices: the result of RMA/ATR at index `j` corresponds to the original data at index `j + mcl - 1`.
const rma_atr_base_index = i - (mcl - 1);
const center_ma_index = rma_atr_base_index - mcl_2;
if (center_ma_index >= 0 && rma_atr_base_index >= 0) {
const center = ma_mcl_full[center_ma_index];
const offset = atr_full[rma_atr_base_index] * atr_mult;
topBand.push({
time: data[i].time,
value: center + offset
});
bottomBand.push({
time: data[i].time,
value: center - offset
});
if (center !== undefined && offset !== undefined) {
topBand.push({ time: data[i].time, value: center + offset });
bottomBand.push({ time: data[i].time, value: center - offset });
}
}
}
return { topBand, bottomBand };
}
// --- Main Calculation Functions ---
/**
* Calculates both primary and higher-timeframe Hurst Bands for an entire dataset.
* @param {Array<Object>} data - An array of candle objects.
* @param {Object} params - An object with { cycle, timeframe_mult }.
* @returns {Object} An object containing four arrays: { topBand, bottomBand, topBand_h, bottomBand_h }.
*/
function calculateFullHurst(data, params) {
const { cycle, timeframe_mult } = params;
// 1. Calculate Primary Bands (e.g., 1-minute)
const primaryBands = _calculateSingleBandSet(data, cycle, 1.8);
// 2. Aggregate candles to higher timeframe (e.g., 5-minute)
const higherTfData = _aggregateCandles(data, timeframe_mult);
// 3. Calculate Higher Timeframe Bands
const higherTFBandsRaw = _calculateSingleBandSet(higherTfData, cycle, 1.9);
// 4. Align higher timeframe results back to the primary timeframe for plotting
const higherTfResults = new Map(higherTFBandsRaw.topBand.map((p, i) => [
p.time,
{ top: p.value, bottom: higherTFBandsRaw.bottomBand[i].value }
]));
const topBand_h = [];
const bottomBand_h = [];
let lastKnownTop = null;
let lastKnownBottom = null;
for (const candle of data) {
if (higherTfResults.has(candle.time)) {
const bands = higherTfResults.get(candle.time);
lastKnownTop = bands.top;
lastKnownBottom = bands.bottom;
}
// Carry forward the last known value until a new one is calculated
if (lastKnownTop !== null) {
topBand_h.push({ time: candle.time, value: lastKnownTop });
bottomBand_h.push({ time: candle.time, value: lastKnownBottom });
}
}
return { topBand, bottomBand };
return {
topBand: primaryBands.topBand,
bottomBand: primaryBands.bottomBand,
topBand_h,
bottomBand_h,
};
}
/**
* Creates a stateful Hurst calculator for real-time updates.
* Note: Due to the lookback (`[mcl_2]`), a truly efficient real-time version is complex.
* This version recalculates on a rolling buffer for simplicity and correctness.
* @param {Object} params - An object with { cycle, atr_mult }.
* @param {Object} params - An object with { cycle, timeframe_mult }.
* @returns {Object} A calculator object with `update` and `prime` methods.
*/
function createRealtimeHurstCalculator(params) {
const bufferSize = params.cycle * 2; // Use a buffer to handle lookbacks
const { cycle, timeframe_mult } = params;
// Buffer needs to be large enough to contain enough aggregated candles for a valid calculation.
const minHigherTfCandles = cycle + Math.floor(Math.floor(cycle / 2) / 2);
const bufferSize = minHigherTfCandles * timeframe_mult * 2; // Use a safe buffer size
let buffer = [];
return {
@ -142,23 +197,26 @@ function createRealtimeHurstCalculator(params) {
if (buffer.length > bufferSize) {
buffer.shift();
}
if (buffer.length < params.cycle + Math.floor(params.cycle / 4)) {
// Check if there's enough data for at least one calculation on the higher timeframe.
const requiredLength = minHigherTfCandles * timeframe_mult;
if (buffer.length < requiredLength) {
return null;
}
// Recalculate on the small buffer
const result = calculateFullHurst(buffer, params);
if (result.topBand.length > 0) {
// Return the last calculated point for each band
const lastTop = result.topBand[result.topBand.length - 1];
const lastBottom = result.bottomBand[result.bottomBand.length - 1];
// The manager will expect an object matching the keys from calculateFull
return { topBand: lastTop, bottomBand: lastBottom };
if (result.topBand.length > 0 && result.topBand_h.length > 0) {
return {
topBand: result.topBand[result.topBand.length - 1],
bottomBand: result.bottomBand[result.bottomBand.length - 1],
topBand_h: result.topBand_h[result.topBand_h.length - 1],
bottomBand_h: result.bottomBand_h[result.bottomBand_h.length - 1],
};
}
return null;
},
prime: function(historicalCandles) {
// Prime the buffer with the last N candles from history
buffer = historicalCandles.slice(-bufferSize);
}
};

View File

@ -7,18 +7,17 @@
*/
function createIndicatorManager(chart, baseCandleDataRef, displayedCandleDataRef) {
const indicatorSlots = [
{ id: 1, cellId: 'indicator-cell-1', series: [], definition: null, params: {} },
{ id: 2, cellId: 'indicator-cell-2', series: [], definition: null, params: {} },
{ id: 3, cellId: 'indicator-cell-3', series: [], definition: null, params: {} },
{ id: 4, cellId: 'indicator-cell-4', series: [], definition: null, params: {} },
{ id: 1, cellId: 'indicator-cell-1', series: [], definition: null, params: {}, calculator: null },
{ id: 2, cellId: 'indicator-cell-2', series: [], definition: null, params: {}, calculator: null },
{ id: 3, cellId: 'indicator-cell-3', series: [], definition: null, params: {}, calculator: null },
{ id: 4, cellId: 'indicator-cell-4', series: [], definition: null, params: {}, calculator: null },
];
// **FIX**: Updated colors object to match your styling request.
const colors = {
bb1: { upper: '#FF9800', lower: '#FF9800' },
bb2: { upper: '#2196F3', lower: '#2196F3' },
bb3: { upper: '#9C27B0', lower: '#9C27B0' },
hurst: { topBand: '#673ab7', bottomBand: '#673ab7' },
default: ['#FF5722', '#03A9F4', '#8BC34A', '#F44336']
bb: { bb1_upper: '#FF9800', bb1_lower: '#FF9800', bb2_upper: '#2196F3', bb2_lower: '#2196F3', bb3_upper: '#9C27B0', bb3_lower: '#9C27B0' },
hurst: { topBand: '#787b86', bottomBand: '#787b86', topBand_h: '#673ab7', bottomBand_h: '#673ab7' },
default: ['#00BCD4', '#FFEB3B', '#4CAF50', '#E91E63'] // Cyan, Yellow, Green, Pink
};
function populateDropdowns() {
@ -51,6 +50,7 @@ function createIndicatorManager(chart, baseCandleDataRef, displayedCandleDataRef
slot.series = [];
slot.definition = null;
slot.params = {};
slot.calculator = null;
const controlsContainer = document.querySelector(`#${slot.cellId} .indicator-controls`);
controlsContainer.innerHTML = '';
@ -80,7 +80,7 @@ function createIndicatorManager(chart, baseCandleDataRef, displayedCandleDataRef
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
slot.params[param.name] = input.type === 'number' ? parseFloat(input.value) : input.value;
updateIndicator(slot.id);
updateIndicator(slot.id, true);
}, 500);
});
const controlGroup = document.createElement('div');
@ -91,45 +91,69 @@ function createIndicatorManager(chart, baseCandleDataRef, displayedCandleDataRef
controlsContainer.appendChild(controlGroup);
});
updateIndicator(slot.id);
updateIndicator(slot.id, true);
}
function updateIndicator(slotId) {
function updateIndicator(slotId, isFullRecalculation = false) {
const slot = indicatorSlots.find(s => s.id === slotId);
const candleDataForCalc = (slot.definition.usesBaseData) ? baseCandleDataRef : displayedCandleDataRef;
if (!slot || !slot.definition || candleDataForCalc.length === 0) {
return;
}
if (!slot || !slot.definition || candleDataForCalc.length === 0) return;
slot.series.forEach(s => chart.removeSeries(s));
slot.series = [];
const indicatorResult = slot.definition.calculateFull(candleDataForCalc, slot.params);
if (typeof indicatorResult === 'object' && !Array.isArray(indicatorResult)) {
Object.keys(indicatorResult).forEach(key => {
const seriesData = indicatorResult[key];
const indicatorNameLower = slot.definition.name.toLowerCase();
const series = chart.addLineSeries({
color: (colors[indicatorNameLower] && colors[indicatorNameLower][key]) ? colors[indicatorNameLower][key] : colors.default[slot.id - 1],
lineWidth: indicatorNameLower === 'hurst' ? 1 : 2,
title: `${slot.definition.label} - ${key}`,
lastValueVisible: false,
priceLineVisible: false,
if (isFullRecalculation) {
slot.series.forEach(s => chart.removeSeries(s));
slot.series = [];
const indicatorResult = slot.definition.calculateFull(candleDataForCalc, slot.params);
if (typeof indicatorResult === 'object' && !Array.isArray(indicatorResult)) {
Object.keys(indicatorResult).forEach(key => {
const seriesData = indicatorResult[key];
const indicatorNameLower = slot.definition.name.toLowerCase();
const series = chart.addLineSeries({
color: (colors[indicatorNameLower] && colors[indicatorNameLower][key]) ? colors[indicatorNameLower][key] : colors.default[slot.id - 1],
lineWidth: 1, // **FIX**: Set line width to 1px
title: '', // **FIX**: Remove title label
lastValueVisible: false, // **FIX**: Remove price label on the right
priceLineVisible: false, // **FIX**: Remove dotted horizontal line
});
series.setData(seriesData);
slot.series.push(series);
});
series.setData(seriesData);
} else {
const series = chart.addLineSeries({
color: colors.default[slot.id - 1],
lineWidth: 1, // **FIX**: Set line width to 1px
title: '', // **FIX**: Remove title label
lastValueVisible: false, // **FIX**: Remove price label on the right
priceLineVisible: false, // **FIX**: Remove dotted horizontal line
});
series.setData(indicatorResult);
slot.series.push(series);
});
} else {
const series = chart.addLineSeries({
color: colors.default[slot.id - 1],
lineWidth: 2,
title: slot.definition.label,
});
series.setData(indicatorResult);
slot.series.push(series);
}
if (slot.definition.createRealtime) {
slot.calculator = slot.definition.createRealtime(slot.params);
slot.calculator.prime(candleDataForCalc);
}
} else if (slot.calculator) {
// **FIX**: This is the lightweight real-time update logic
const lastCandle = candleDataForCalc[candleDataForCalc.length - 1];
if (!lastCandle) return;
const newPoint = slot.calculator.update(lastCandle);
if (newPoint && typeof newPoint === 'object') {
if (slot.series.length > 1) { // Multi-line indicator
Object.keys(newPoint).forEach((key, index) => {
if (slot.series[index] && newPoint[key]) {
slot.series[index].update(newPoint[key]);
}
});
} else if (slot.series.length === 1) { // Single-line indicator
slot.series[0].update(newPoint);
}
}
}
}
@ -137,12 +161,22 @@ function createIndicatorManager(chart, baseCandleDataRef, displayedCandleDataRef
baseCandleDataRef = baseData;
displayedCandleDataRef = displayedData;
indicatorSlots.forEach(slot => {
if (slot.definition) updateIndicator(slot.id);
if (slot.definition) updateIndicator(slot.id, true);
});
}
// **FIX**: New lightweight function for real-time updates
function updateAllOnNewCandle() {
indicatorSlots.forEach(slot => {
if (slot.definition) {
updateIndicator(slot.id, false); // Perform a lightweight update
}
});
}
return {
populateDropdowns,
recalculateAllAfterHistory,
updateAllOnNewCandle, // Expose the new function
};
}