chore: add AGENTS.md with build, lint, test commands and style guidelines
This commit is contained in:
421
js/indicators/hurst.js
Normal file
421
js/indicators/hurst.js
Normal file
@ -0,0 +1,421 @@
|
||||
// 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 };
|
||||
Reference in New Issue
Block a user