422 lines
16 KiB
JavaScript
422 lines
16 KiB
JavaScript
// 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 = `/api/v1/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 };
|