refactor: modularize dashboard strategies and enhance indicator engine

- Refactored strategy-panel.js to use a modular registry system for trading strategies.
- Introduced PingPongStrategy and moved strategy-specific logic to a new strategies/ directory.
- Enhanced the indicator engine with Multi-Timeframe (MTF) support and robust forward-filling.
- Optimized BaseIndicator and RMA calculations for better performance.
- Updated UI components (chart.js, indicators-panel, signal-markers) to support the new architecture.
- Added markers-plugin.js for improved signal visualization.
This commit is contained in:
DiTus
2026-03-10 11:52:11 +01:00
parent 8b167f8b2c
commit 218f0f5107
11 changed files with 1310 additions and 599 deletions

View File

@ -2,6 +2,8 @@
// 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'
@ -14,47 +16,61 @@ const SIGNAL_COLORS = {
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 || '1m';
this.timeframe = config.timeframe || 'chart';
this.series = [];
this.visible = config.visible !== false;
this.cachedResults = null;
this.cachedMeta = null;
this.lastSignalTimestamp = null;
this.lastSignalType = null;
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; }
}
// Calculate RMA (Rolling Moving Average - Wilder's method)
// Recreates Pine Script's ta.rma() exactly
// 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;
// PineScript implicitly rounds float lengths for SMA initialization
const smaLength = Math.round(length);
for (let i = 0; i < sourceArray.length; i++) {
if (i < smaLength - 1) {
// Accumulate first N-1 bars
sum += sourceArray[i];
} else if (i === smaLength - 1) {
// On the Nth bar, the first RMA value is the SMA
sum += sourceArray[i];
rma[i] = sum / smaLength;
} else {
// Subsequent bars use the RMA formula
const prevRMA = rma[i - 1];
rma[i] = (prevRMA === null || isNaN(prevRMA))
? alpha * sourceArray[i]
? sourceArray[i]
: alpha * sourceArray[i] + (1 - alpha) * prevRMA;
}
}
return rma;
}
@ -93,12 +109,76 @@ function calculateHurstSignal(indicator, lastCandle, prevCandle, values, prevVal
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);
this.lastSignalTimestamp = null;
this.lastSignalType = null;
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';
@ -108,59 +188,207 @@ export class HurstBandsIndicator extends BaseIndicator {
}
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;
// FIX: PineScript rounds implicit floats for history references [].
// 15/2 = 7.5. Pine rounds this to 8. Math.floor gives 7.
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;
const high = d.high;
const low = d.low;
if (prevClose === null || prevClose === undefined || isNaN(prevClose)) {
return high - low;
}
return Math.max(
high - low,
Math.abs(high - prevClose),
Math.abs(low - prevClose)
);
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 src = closes[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 || historical_ma === undefined || isNaN(historical_ma)) ? src : historical_ma;
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
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 }
],
@ -174,6 +402,20 @@ export class HurstBandsIndicator extends BaseIndicator {
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 };