chore: add AGENTS.md with build, lint, test commands and style guidelines

This commit is contained in:
DiTus
2026-03-18 21:17:43 +01:00
parent e98c25efc4
commit 509f8033fa
32 changed files with 10087 additions and 133 deletions

118
js/indicators/atr.js Normal file
View File

@ -0,0 +1,118 @@
// Self-contained ATR indicator
// Includes math, metadata, signal calculation, and base class
// Signal constants (defined in each indicator file)
const SIGNAL_TYPES = {
BUY: 'buy',
SELL: 'sell',
HOLD: 'hold'
};
const SIGNAL_COLORS = {
buy: '#26a69a',
hold: '#787b86',
sell: '#ef5350'
};
// Base class (inline replacement for BaseIndicator)
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;
}
}
// Signal calculation for ATR
function calculateATRSignal(indicator, lastCandle, prevCandle, values) {
const atr = values?.atr;
const close = lastCandle.close;
const prevClose = prevCandle?.close;
if (!atr || atr === null || !prevClose) {
return null;
}
const atrPercent = atr / close * 100;
const priceChange = Math.abs(close - prevClose);
const atrRatio = priceChange / atr;
if (atrRatio > 1.5) {
return {
type: SIGNAL_TYPES.HOLD,
strength: 70,
value: atr,
reasoning: `High volatility: ATR (${atr.toFixed(2)}, ${atrPercent.toFixed(2)}%)`
};
}
return null;
}
// ATR Indicator class
export class ATRIndicator extends BaseIndicator {
constructor(config) {
super(config);
this.lastSignalTimestamp = null;
this.lastSignalType = null;
}
calculate(candles) {
const period = this.params.period || 14;
const results = new Array(candles.length).fill(null);
const tr = new Array(candles.length).fill(0);
for (let i = 1; i < candles.length; i++) {
const h_l = candles[i].high - candles[i].low;
const h_pc = Math.abs(candles[i].high - candles[i-1].close);
const l_pc = Math.abs(candles[i].low - candles[i-1].close);
tr[i] = Math.max(h_l, h_pc, l_pc);
}
let atr = 0;
let sum = 0;
for (let i = 1; i <= period; i++) sum += tr[i];
atr = sum / period;
results[period] = atr;
for (let i = period + 1; i < candles.length; i++) {
atr = (atr * (period - 1) + tr[i]) / period;
results[i] = atr;
}
return results.map(atr => ({ atr }));
}
getMetadata() {
return {
name: 'ATR',
description: 'Average True Range - measures market volatility',
inputs: [{
name: 'period',
label: 'Period',
type: 'number',
default: 14,
min: 1,
max: 100,
description: 'Period for ATR calculation'
}],
plots: [{
id: 'value',
color: '#795548',
title: 'ATR',
lineWidth: 1
}],
displayMode: 'pane'
};
}
}
export { calculateATRSignal };

118
js/indicators/bb.js Normal file
View File

@ -0,0 +1,118 @@
// Self-contained Bollinger Bands indicator
// Includes math, metadata, signal calculation, and base class
// Signal constants (defined in each indicator file)
const SIGNAL_TYPES = {
BUY: 'buy',
SELL: 'sell',
HOLD: 'hold'
};
const SIGNAL_COLORS = {
buy: '#26a69a',
hold: '#787b86',
sell: '#ef5350'
};
// Base class (inline replacement for BaseIndicator)
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;
}
}
// Signal calculation for Bollinger Bands
function calculateBollingerBandsSignal(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 (!upper || !lower || prevUpper === undefined || prevLower === undefined || prevClose === undefined) {
return null;
}
// BUY: Price crosses DOWN through lower band (reversal/bounce play)
if (prevClose > prevLower && close <= lower) {
return {
type: SIGNAL_TYPES.BUY,
strength: 70,
value: close,
reasoning: `Price crossed DOWN through lower Bollinger Band`
};
}
// SELL: Price crosses UP through upper band (overextended play)
else if (prevClose < prevUpper && close >= upper) {
return {
type: SIGNAL_TYPES.SELL,
strength: 70,
value: close,
reasoning: `Price crossed UP through upper Bollinger Band`
};
}
return null;
}
// Bollinger Bands Indicator class
export class BollingerBandsIndicator extends BaseIndicator {
constructor(config) {
super(config);
this.lastSignalTimestamp = null;
this.lastSignalType = null;
}
calculate(candles) {
const period = this.params.period || 20;
const stdDevMult = this.params.stdDev || 2;
const results = new Array(candles.length).fill(null);
for (let i = period - 1; i < candles.length; i++) {
let sum = 0;
for (let j = 0; j < period; j++) sum += candles[i-j].close;
const sma = sum / period;
let diffSum = 0;
for (let j = 0; j < period; j++) diffSum += Math.pow(candles[i-j].close - sma, 2);
const stdDev = Math.sqrt(diffSum / period);
results[i] = {
middle: sma,
upper: sma + (stdDevMult * stdDev),
lower: sma - (stdDevMult * stdDev)
};
}
return results;
}
getMetadata() {
return {
name: 'Bollinger Bands',
description: 'Volatility bands around a moving average',
inputs: [
{ name: 'period', label: 'Period', type: 'number', default: 20, min: 1, max: 100 },
{ name: 'stdDev', label: 'Std Dev', type: 'number', default: 2, min: 0.5, max: 5, step: 0.5 }
],
plots: [
{ id: 'upper', color: '#4caf50', title: 'Upper' },
{ id: 'middle', color: '#4caf50', title: 'Middle', lineStyle: 2 },
{ id: 'lower', color: '#4caf50', title: 'Lower' }
],
displayMode: 'overlay'
};
}
}
export { calculateBollingerBandsSignal };

255
js/indicators/hts.js Normal file
View File

@ -0,0 +1,255 @@
// Self-contained HTS Trend System indicator
// Includes math, metadata, signal calculation, and base class
// Signal constants (defined in each indicator file)
const SIGNAL_TYPES = {
BUY: 'buy',
SELL: 'sell',
HOLD: 'hold'
};
const SIGNAL_COLORS = {
buy: '#26a69a',
hold: '#787b86',
sell: '#ef5350'
};
// Base class (inline replacement for BaseIndicator)
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;
}
}
// MA calculations inline (SMA/EMA/RMA/WMA/VWMA)
function calculateSMA(candles, period, source = 'close') {
const results = new Array(candles.length).fill(null);
let sum = 0;
for (let i = 0; i < candles.length; i++) {
sum += candles[i][source];
if (i >= period) sum -= candles[i - period][source];
if (i >= period - 1) results[i] = sum / period;
}
return results;
}
function calculateEMA(candles, period, source = 'close') {
const multiplier = 2 / (period + 1);
const results = new Array(candles.length).fill(null);
let ema = 0;
let sum = 0;
for (let i = 0; i < candles.length; i++) {
if (i < period) {
sum += candles[i][source];
if (i === period - 1) {
ema = sum / period;
results[i] = ema;
}
} else {
ema = (candles[i][source] - ema) * multiplier + ema;
results[i] = ema;
}
}
return results;
}
function calculateRMA(candles, period, source = 'close') {
const multiplier = 1 / period;
const results = new Array(candles.length).fill(null);
let rma = 0;
let sum = 0;
for (let i = 0; i < candles.length; i++) {
if (i < period) {
sum += candles[i][source];
if (i === period - 1) {
rma = sum / period;
results[i] = rma;
}
} else {
rma = (candles[i][source] - rma) * multiplier + rma;
results[i] = rma;
}
}
return results;
}
function calculateWMA(candles, period, source = 'close') {
const results = new Array(candles.length).fill(null);
const weightSum = (period * (period + 1)) / 2;
for (let i = period - 1; i < candles.length; i++) {
let sum = 0;
for (let j = 0; j < period; j++) {
sum += candles[i - j][source] * (period - j);
}
results[i] = sum / weightSum;
}
return results;
}
function calculateVWMA(candles, period, source = 'close') {
const results = new Array(candles.length).fill(null);
for (let i = period - 1; i < candles.length; i++) {
let sumPV = 0;
let sumV = 0;
for (let j = 0; j < period; j++) {
sumPV += candles[i - j][source] * candles[i - j].volume;
sumV += candles[i - j].volume;
}
results[i] = sumV !== 0 ? sumPV / sumV : null;
}
return results;
}
// MA dispatcher function
function getMA(type, candles, period, source = 'close') {
switch (type.toUpperCase()) {
case 'SMA': return calculateSMA(candles, period, source);
case 'EMA': return calculateEMA(candles, period, source);
case 'RMA': return calculateRMA(candles, period, source);
case 'WMA': return calculateWMA(candles, period, source);
case 'VWMA': return calculateVWMA(candles, period, source);
default: return calculateSMA(candles, period, source);
}
}
// Signal calculation for HTS
function calculateHTSSignal(indicator, lastCandle, prevCandle, values, prevValues) {
const slowLow = values?.slowLow;
const slowHigh = values?.slowHigh;
const prevSlowLow = prevValues?.slowLow;
const prevSlowHigh = prevValues?.slowHigh;
if (!slowLow || !slowHigh || !prevSlowLow || !prevSlowHigh) {
return null;
}
const close = lastCandle.close;
const prevClose = prevCandle?.close;
if (prevClose === undefined) return null;
// BUY: Price crosses UP through slow low
if (prevClose <= prevSlowLow && close > slowLow) {
return {
type: SIGNAL_TYPES.BUY,
strength: 85,
value: close,
reasoning: `Price crossed UP through slow low`
};
}
// SELL: Price crosses DOWN through slow high
else if (prevClose >= prevSlowHigh && close < slowHigh) {
return {
type: SIGNAL_TYPES.SELL,
strength: 85,
value: close,
reasoning: `Price crossed DOWN through slow high`
};
}
return null;
}
// HTS Indicator class
export class HTSIndicator extends BaseIndicator {
constructor(config) {
super(config);
this.lastSignalTimestamp = null;
this.lastSignalType = null;
}
calculate(candles, oneMinCandles = null, targetTF = null) {
const shortPeriod = this.params.short || 33;
const longPeriod = this.params.long || 144;
const maType = this.params.maType || 'RMA';
const useAutoHTS = this.params.useAutoHTS || false;
let workingCandles = candles;
if (useAutoHTS && oneMinCandles && targetTF) {
const tfMultipliers = {
'5m': 5,
'15m': 15,
'30m': 30,
'37m': 37,
'1h': 60,
'4h': 240
};
const tfGroup = tfMultipliers[targetTF] || 5;
const grouped = [];
let currentGroup = [];
for (let i = 0; i < oneMinCandles.length; i++) {
currentGroup.push(oneMinCandles[i]);
if (currentGroup.length >= tfGroup) {
grouped.push({
time: currentGroup[tfGroup - 1].time,
open: currentGroup[tfGroup - 1].open,
high: currentGroup[tfGroup - 1].high,
low: currentGroup[tfGroup - 1].low,
close: currentGroup[tfGroup - 1].close,
volume: currentGroup[tfGroup - 1].volume
});
currentGroup = [];
}
}
workingCandles = grouped;
}
const shortHigh = getMA(maType, workingCandles, shortPeriod, 'high');
const shortLow = getMA(maType, workingCandles, shortPeriod, 'low');
const longHigh = getMA(maType, workingCandles, longPeriod, 'high');
const longLow = getMA(maType, workingCandles, longPeriod, 'low');
return workingCandles.map((_, i) => ({
fastHigh: shortHigh[i],
fastLow: shortLow[i],
slowHigh: longHigh[i],
slowLow: longLow[i],
fastMidpoint: ((shortHigh[i] || 0) + (shortLow[i] || 0)) / 2,
slowMidpoint: ((longHigh[i] || 0) + (longLow[i] || 0)) / 2
}));
}
getMetadata() {
const useAutoHTS = this.params?.useAutoHTS || false;
const fastLineWidth = useAutoHTS ? 1 : 1;
const slowLineWidth = useAutoHTS ? 2 : 2;
return {
name: 'HTS Trend System',
description: 'High/Low Trend System with Fast and Slow MAs',
inputs: [
{ name: 'short', label: 'Fast Period', type: 'number', default: 33, min: 1, max: 500 },
{ name: 'long', label: 'Slow Period', type: 'number', default: 144, min: 1, max: 500 },
{ name: 'maType', label: 'MA Type', type: 'select', options: ['SMA', 'EMA', 'RMA', 'WMA', 'VWMA'], default: 'RMA' },
{ name: 'useAutoHTS', label: 'Auto HTS (TF/4)', type: 'boolean', default: false }
],
plots: [
{ id: 'fastHigh', color: '#00bcd4', title: 'Fast High', width: fastLineWidth },
{ id: 'fastLow', color: '#00bcd4', title: 'Fast Low', width: fastLineWidth },
{ id: 'slowHigh', color: '#f44336', title: 'Slow High', width: slowLineWidth },
{ id: 'slowLow', color: '#f44336', title: 'Slow Low', width: slowLineWidth }
],
displayMode: 'overlay'
};
}
}
export { calculateHTSSignal };

421
js/indicators/hurst.js Normal file
View 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 };

69
js/indicators/index.js Normal file
View File

@ -0,0 +1,69 @@
// Indicator registry and exports for self-contained indicators
// Import all indicator classes and their signal functions
export { MAIndicator, calculateMASignal } from './moving_average.js';
export { MACDIndicator, calculateMACDSignal } from './macd.js';
export { HTSIndicator, calculateHTSSignal } from './hts.js';
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';
import { MACDIndicator as MACDI, calculateMACDSignal as CMC } from './macd.js';
import { HTSIndicator as HTSI, calculateHTSSignal as CHTS } from './hts.js';
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 = {
ma: CMA,
macd: CMC,
hts: CHTS,
rsi: CRSI,
bb: CBB,
stoch: CST,
atr: CATR,
hurst: CHURST
};
// Indicator registry for UI
export const IndicatorRegistry = {
ma: MAI,
macd: MACDI,
hts: HTSI,
rsi: RSII,
bb: BBI,
stoch: STOCHI,
atr: ATRI,
hurst: HURSTI
};
/**
* Get list of available indicators for the UI catalog
*/
export function getAvailableIndicators() {
return Object.entries(IndicatorRegistry).map(([type, IndicatorClass]) => {
const instance = new IndicatorClass({ type, params: {}, name: '' });
const meta = instance.getMetadata();
return {
type,
name: meta.name || type.toUpperCase(),
description: meta.description || ''
};
});
}
/**
* Get signal function for an indicator type
* @param {string} indicatorType - The type of indicator (e.g., 'ma', 'rsi')
* @returns {Function|null} The signal calculation function or null if not found
*/
export function getSignalFunction(indicatorType) {
return SignalFunctionRegistry[indicatorType] || null;
}

153
js/indicators/macd.js Normal file
View File

@ -0,0 +1,153 @@
// Self-contained MACD indicator
// Includes math, metadata, signal calculation, and base class
// Signal constants (defined in each indicator file)
const SIGNAL_TYPES = {
BUY: 'buy',
SELL: 'sell',
HOLD: 'hold'
};
const SIGNAL_COLORS = {
buy: '#26a69a',
hold: '#787b86',
sell: '#ef5350'
};
// Base class (inline replacement for BaseIndicator)
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;
}
}
// EMA calculation inline (needed for MACD)
function calculateEMAInline(data, period) {
const multiplier = 2 / (period + 1);
const ema = [];
for (let i = 0; i < data.length; i++) {
if (i < period - 1) {
ema.push(null);
} else if (i === period - 1) {
ema.push(data[i]);
} else {
ema.push((data[i] - ema[i - 1]) * multiplier + ema[i - 1]);
}
}
return ema;
}
// Signal calculation for MACD
function calculateMACDSignal(indicator, lastCandle, prevCandle, values, prevValues) {
const macd = values?.macd;
const signal = values?.signal;
const prevMacd = prevValues?.macd;
const prevSignal = prevValues?.signal;
if (macd === undefined || macd === null || signal === undefined || signal === null ||
prevMacd === undefined || prevMacd === null || prevSignal === undefined || prevSignal === null) {
return null;
}
// BUY: MACD crosses UP through Signal line
if (prevMacd <= prevSignal && macd > signal) {
return {
type: SIGNAL_TYPES.BUY,
strength: 80,
value: macd,
reasoning: `MACD crossed UP through Signal line`
};
}
// SELL: MACD crosses DOWN through Signal line
else if (prevMacd >= prevSignal && macd < signal) {
return {
type: SIGNAL_TYPES.SELL,
strength: 80,
value: macd,
reasoning: `MACD crossed DOWN through Signal line`
};
}
return null;
}
// MACD Indicator class
export class MACDIndicator extends BaseIndicator {
constructor(config) {
super(config);
this.lastSignalTimestamp = null;
this.lastSignalType = null;
}
calculate(candles) {
const fast = this.params.fast || 12;
const slow = this.params.slow || 26;
const signalPeriod = this.params.signal || 9;
const closes = candles.map(c => c.close);
// Use inline EMA calculation instead of MA.ema()
const fastEMA = calculateEMAInline(closes, fast);
const slowEMA = calculateEMAInline(closes, slow);
const macdLine = fastEMA.map((f, i) => (f !== null && slowEMA[i] !== null) ? f - slowEMA[i] : null);
let sum = 0;
let ema = 0;
let count = 0;
const signalLine = macdLine.map(m => {
if (m === null) return null;
count++;
if (count < signalPeriod) {
sum += m;
return null;
} else if (count === signalPeriod) {
sum += m;
ema = sum / signalPeriod;
return ema;
} else {
ema = (m - ema) * (2 / (signalPeriod + 1)) + ema;
return ema;
}
});
return macdLine.map((m, i) => ({
macd: m,
signal: signalLine[i],
histogram: (m !== null && signalLine[i] !== null) ? m - signalLine[i] : null
}));
}
getMetadata() {
return {
name: 'MACD',
description: 'Moving Average Convergence Divergence - trend & momentum',
inputs: [
{ name: 'fast', label: 'Fast Period', type: 'number', default: 12 },
{ name: 'slow', label: 'Slow Period', type: 'number', default: 26 },
{ name: 'signal', label: 'Signal Period', type: 'number', default: 9 }
],
plots: [
{ id: 'macd', color: '#2196f3', title: 'MACD' },
{ id: 'signal', color: '#ff5722', title: 'Signal' },
{ id: 'histogram', color: '#607d8b', title: 'Histogram', type: 'histogram' }
],
displayMode: 'pane'
};
}
}
export { calculateMACDSignal };

View File

@ -0,0 +1,221 @@
// Self-contained Moving Average indicator with SMA/EMA/RMA/WMA/VWMA support
// Includes math, metadata, signal calculation, and base class
// Signal constants (defined in each indicator file)
const SIGNAL_TYPES = {
BUY: 'buy',
SELL: 'sell',
HOLD: 'hold'
};
const SIGNAL_COLORS = {
buy: '#26a69a',
hold: '#787b86',
sell: '#ef5350'
};
// Base class (inline replacement for BaseIndicator)
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;
}
}
// Moving Average math (SMA/EMA/RMA/WMA/VWMA)
function calculateSMA(candles, period, source = 'close') {
const results = new Array(candles.length).fill(null);
let sum = 0;
for (let i = 0; i < candles.length; i++) {
sum += candles[i][source];
if (i >= period) sum -= candles[i - period][source];
if (i >= period - 1) results[i] = sum / period;
}
return results;
}
function calculateEMA(candles, period, source = 'close') {
const multiplier = 2 / (period + 1);
const results = new Array(candles.length).fill(null);
let ema = 0;
let sum = 0;
for (let i = 0; i < candles.length; i++) {
if (i < period) {
sum += candles[i][source];
if (i === period - 1) {
ema = sum / period;
results[i] = ema;
}
} else {
ema = (candles[i][source] - ema) * multiplier + ema;
results[i] = ema;
}
}
return results;
}
function calculateRMA(candles, period, source = 'close') {
const multiplier = 1 / period;
const results = new Array(candles.length).fill(null);
let rma = 0;
let sum = 0;
for (let i = 0; i < candles.length; i++) {
if (i < period) {
sum += candles[i][source];
if (i === period - 1) {
rma = sum / period;
results[i] = rma;
}
} else {
rma = (candles[i][source] - rma) * multiplier + rma;
results[i] = rma;
}
}
return results;
}
function calculateWMA(candles, period, source = 'close') {
const results = new Array(candles.length).fill(null);
const weightSum = (period * (period + 1)) / 2;
for (let i = period - 1; i < candles.length; i++) {
let sum = 0;
for (let j = 0; j < period; j++) {
sum += candles[i - j][source] * (period - j);
}
results[i] = sum / weightSum;
}
return results;
}
function calculateVWMA(candles, period, source = 'close') {
const results = new Array(candles.length).fill(null);
for (let i = period - 1; i < candles.length; i++) {
let sumPV = 0;
let sumV = 0;
for (let j = 0; j < period; j++) {
sumPV += candles[i - j][source] * candles[i - j].volume;
sumV += candles[i - j].volume;
}
results[i] = sumV !== 0 ? sumPV / sumV : null;
}
return results;
}
// Signal calculation for Moving Average
function calculateMASignal(indicator, lastCandle, prevCandle, values, prevValues) {
const close = lastCandle.close;
const prevClose = prevCandle?.close;
const ma = values?.ma;
const prevMa = prevValues?.ma;
if (!ma && ma !== 0) return null;
if (prevClose === undefined || prevMa === undefined || prevMa === null) return null;
// BUY: Price crosses UP through MA
if (prevClose <= prevMa && close > ma) {
return {
type: SIGNAL_TYPES.BUY,
strength: 80,
value: close,
reasoning: `Price crossed UP through MA`
};
}
// SELL: Price crosses DOWN through MA
else if (prevClose >= prevMa && close < ma) {
return {
type: SIGNAL_TYPES.SELL,
strength: 80,
value: close,
reasoning: `Price crossed DOWN through MA`
};
}
return null;
}
// MA Indicator class
export class MAIndicator extends BaseIndicator {
constructor(config) {
super(config);
this.lastSignalTimestamp = null;
this.lastSignalType = null;
}
calculate(candles) {
const maType = (this.params.maType || 'SMA').toLowerCase();
const period = this.params.period || 44;
let maValues;
switch (maType) {
case 'sma':
maValues = calculateSMA(candles, period, this.params.source || 'close');
break;
case 'ema':
maValues = calculateEMA(candles, period, this.params.source || 'close');
break;
case 'rma':
maValues = calculateRMA(candles, period, this.params.source || 'close');
break;
case 'wma':
maValues = calculateWMA(candles, period, this.params.source || 'close');
break;
case 'vwma':
maValues = calculateVWMA(candles, period, this.params.source || 'close');
break;
default:
maValues = calculateSMA(candles, period, this.params.source || 'close');
}
return maValues.map(ma => ({ ma }));
}
getMetadata() {
return {
name: 'MA',
description: 'Moving Average (SMA/EMA/RMA/WMA/VWMA)',
inputs: [
{
name: 'period',
label: 'Period',
type: 'number',
default: 44,
min: 1,
max: 500
},
{
name: 'maType',
label: 'MA Type',
type: 'select',
options: ['SMA', 'EMA', 'RMA', 'WMA', 'VWMA'],
default: 'SMA'
}
],
plots: [
{
id: 'ma',
color: '#2962ff',
title: 'MA',
style: 'solid',
width: 1
}
],
displayMode: 'overlay'
};
}
}
// Export signal function for external use
export { calculateMASignal };

141
js/indicators/rsi.js Normal file
View File

@ -0,0 +1,141 @@
// Self-contained RSI indicator
// Includes math, metadata, signal calculation, and base class
// Signal constants (defined in each indicator file)
const SIGNAL_TYPES = {
BUY: 'buy',
SELL: 'sell',
HOLD: 'hold'
};
const SIGNAL_COLORS = {
buy: '#26a69a',
hold: '#787b86',
sell: '#ef5350'
};
// Base class (inline replacement for BaseIndicator)
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;
}
}
// Signal calculation for RSI
function calculateRSISignal(indicator, lastCandle, prevCandle, values, prevValues) {
const rsi = values?.rsi;
const prevRsi = prevValues?.rsi;
const overbought = indicator.params?.overbought || 70;
const oversold = indicator.params?.oversold || 30;
if (rsi === undefined || rsi === null || prevRsi === undefined || prevRsi === null) {
return null;
}
// BUY when RSI crosses UP through oversold level
if (prevRsi < oversold && rsi >= oversold) {
return {
type: SIGNAL_TYPES.BUY,
strength: 75,
value: rsi,
reasoning: `RSI crossed UP through oversold level (${oversold})`
};
}
// SELL when RSI crosses DOWN through overbought level
else if (prevRsi > overbought && rsi <= overbought) {
return {
type: SIGNAL_TYPES.SELL,
strength: 75,
value: rsi,
reasoning: `RSI crossed DOWN through overbought level (${overbought})`
};
}
return null;
}
// RSI Indicator class
export class RSIIndicator extends BaseIndicator {
constructor(config) {
super(config);
this.lastSignalTimestamp = null;
this.lastSignalType = null;
}
calculate(candles) {
const period = this.params.period || 14;
const overbought = this.params.overbought || 70;
const oversold = this.params.oversold || 30;
// 1. Calculate RSI using RMA (Wilder's Smoothing)
let rsiValues = new Array(candles.length).fill(null);
let upSum = 0;
let downSum = 0;
const rmaAlpha = 1 / period;
for (let i = 1; i < candles.length; i++) {
const diff = candles[i].close - candles[i-1].close;
const up = diff > 0 ? diff : 0;
const down = diff < 0 ? -diff : 0;
if (i < period) {
upSum += up;
downSum += down;
} else if (i === period) {
upSum += up;
downSum += down;
const avgUp = upSum / period;
const avgDown = downSum / period;
rsiValues[i] = avgDown === 0 ? 100 : (avgUp === 0 ? 0 : 100 - (100 / (1 + avgUp / avgDown)));
upSum = avgUp;
downSum = avgDown;
} else {
upSum = (up - upSum) * rmaAlpha + upSum;
downSum = (down - downSum) * rmaAlpha + downSum;
rsiValues[i] = downSum === 0 ? 100 : (upSum === 0 ? 0 : 100 - (100 / (1 + upSum / downSum)));
}
}
// Combine results
return rsiValues.map((rsi, i) => {
return {
paneBg: 80,
rsi: rsi,
overboughtBand: overbought,
oversoldBand: oversold
};
});
}
getMetadata() {
return {
name: 'RSI',
description: 'Relative Strength Index',
inputs: [
{ name: 'period', label: 'RSI Length', type: 'number', default: 14, min: 1, max: 100 },
{ name: 'overbought', label: 'Overbought Level', type: 'number', default: 70, min: 50, max: 95 },
{ name: 'oversold', label: 'Oversold Level', type: 'number', default: 30, min: 5, max: 50 }
],
plots: [
{ id: 'rsi', color: '#7E57C2', title: '', style: 'solid', width: 1, lastValueVisible: true },
{ id: 'overboughtBand', color: '#787B86', title: '', style: 'dashed', width: 1, lastValueVisible: false },
{ id: 'oversoldBand', color: '#787B86', title: '', style: 'dashed', width: 1, lastValueVisible: false }
],
displayMode: 'pane',
paneMin: 0,
paneMax: 100
};
}
}
export { calculateRSISignal };

139
js/indicators/stoch.js Normal file
View File

@ -0,0 +1,139 @@
// Self-contained Stochastic Oscillator indicator
// Includes math, metadata, signal calculation, and base class
// Signal constants (defined in each indicator file)
const SIGNAL_TYPES = {
BUY: 'buy',
SELL: 'sell',
HOLD: 'hold'
};
const SIGNAL_COLORS = {
buy: '#26a69a',
hold: '#787b86',
sell: '#ef5350'
};
// Base class (inline replacement for BaseIndicator)
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;
}
}
// Signal calculation for Stochastic
function calculateStochSignal(indicator, lastCandle, prevCandle, values, prevValues) {
const k = values?.k;
const d = values?.d;
const prevK = prevValues?.k;
const prevD = prevValues?.d;
const overbought = indicator.params?.overbought || 80;
const oversold = indicator.params?.oversold || 20;
if (k === undefined || d === undefined || prevK === undefined || prevD === undefined) {
return null;
}
// BUY: %K crosses UP through %D while both are oversold
if (prevK <= prevD && k > d && k < oversold) {
return {
type: SIGNAL_TYPES.BUY,
strength: 80,
value: k,
reasoning: `Stochastic %K crossed UP through %D in oversold zone`
};
}
// SELL: %K crosses DOWN through %D while both are overbought
else if (prevK >= prevD && k < d && k > overbought) {
return {
type: SIGNAL_TYPES.SELL,
strength: 80,
value: k,
reasoning: `Stochastic %K crossed DOWN through %D in overbought zone`
};
}
return null;
}
// Stochastic Oscillator Indicator class
export class StochasticIndicator extends BaseIndicator {
constructor(config) {
super(config);
this.lastSignalTimestamp = null;
this.lastSignalType = null;
}
calculate(candles) {
const kPeriod = this.params.kPeriod || 14;
const dPeriod = this.params.dPeriod || 3;
const results = new Array(candles.length).fill(null);
const kValues = new Array(candles.length).fill(null);
for (let i = kPeriod - 1; i < candles.length; i++) {
let lowest = Infinity;
let highest = -Infinity;
for (let j = 0; j < kPeriod; j++) {
lowest = Math.min(lowest, candles[i-j].low);
highest = Math.max(highest, candles[i-j].high);
}
const diff = highest - lowest;
kValues[i] = diff === 0 ? 50 : ((candles[i].close - lowest) / diff) * 100;
}
for (let i = kPeriod + dPeriod - 2; i < candles.length; i++) {
let sum = 0;
for (let j = 0; j < dPeriod; j++) sum += kValues[i-j];
results[i] = { k: kValues[i], d: sum / dPeriod };
}
return results;
}
getMetadata() {
return {
name: 'Stochastic',
description: 'Stochastic Oscillator - compares close to high-low range',
inputs: [
{
name: 'kPeriod',
label: '%K Period',
type: 'number',
default: 14,
min: 1,
max: 100,
description: 'Lookback period for %K calculation'
},
{
name: 'dPeriod',
label: '%D Period',
type: 'number',
default: 3,
min: 1,
max: 20,
description: 'Smoothing period for %D (SMA of %K)'
}
],
plots: [
{ id: 'k', color: '#3f51b5', title: '%K', style: 'solid', width: 1 },
{ id: 'd', color: '#ff9800', title: '%D', style: 'solid', width: 1 }
],
displayMode: 'pane',
paneMin: 0,
paneMax: 100
};
}
}
export { calculateStochSignal };