From d15e281c9a033af8598b104dd70ac42cc953b86a Mon Sep 17 00:00:00 2001 From: AlbertoAmadorBelchistim Date: Sat, 4 Jul 2026 12:01:46 +0200 Subject: [PATCH 1/2] feat(Delta): add average delta line (SMA/EMA) over the delta histogram Adds a configurable moving average overlay on the delta series: - AverageMode enum (SMA / EMA), localized via existing Strings keys. - AverageColorMode enum (Fixed / Zero cross / Slope) controlling the per-bar line color: fixed base color, sign-based, or slope-based. - Properties in a new Average group on the Visualization tab, tagged with [Tab] to follow the settings categorization: ShowAverage, AveragePeriod, AvgMode, AvgColorMode, AverageColor, AvgSlopeUpColor, AvgSlopeDownColor, AverageWidth. - Property names, group and tab reuse existing localization resources (Strings.ShowAverage, Strings.Average, Strings.AveragePeriod, Strings.CalculationMode, Strings.SMA, Strings.EMA, Strings.Width). The four color-mode strings and three enum member names have no matching keys in OFT.Localization yet, so they are declared inline, ready to be swapped to resource keys once they exist. - O(1) rolling SMA (queue + running sum) and standard EMA with state reset at bar 0; no look-ahead. - Hidden by default; the series is appended at the end of DataSeries, so existing templates are unaffected. --- Technical/Delta.cs | 237 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) diff --git a/Technical/Delta.cs b/Technical/Delta.cs index d386744e2..c4e1c2e4a 100644 --- a/Technical/Delta.cs +++ b/Technical/Delta.cs @@ -75,6 +75,26 @@ public enum Location Down } + [Serializable] + public enum AverageMode + { + [Display(ResourceType = typeof(Strings), Name = nameof(Strings.SMA))] + Sma = 0, + [Display(ResourceType = typeof(Strings), Name = nameof(Strings.EMA))] + Ema = 1 + } + + [Serializable] + public enum AverageColorMode + { + [Display(Name = "Fixed")] + Fixed = 0, + [Display(Name = "Zero cross")] + ZeroCross = 1, + [Display(Name = "Slope")] + Slope = 2 + } + #endregion #region Fields @@ -204,6 +224,33 @@ public enum Location IgnoredByAlerts = true }; + #region Fields (average delta) + + private readonly ValueDataSeries _avgSeries = new("AverageDelta", "Average") + { + VisualType = VisualMode.Hide, + Width = 2, + Color = CrossColor.FromArgb(255, 60, 120, 240), + ShowCurrentValue = false + }; + + private bool _showAverage; + private int _averagePeriod = 20; + private AverageMode _avgMode = AverageMode.Sma; + private AverageColorMode _avgColorMode = AverageColorMode.Fixed; + + private Color _averageColor = Color.FromArgb(255, 60, 120, 240); + private Color _avgSlopeUpColor = Color.Green; + private Color _avgSlopeDownColor = Color.Red; + + private readonly Queue _smaWindow = new(); + private decimal _smaSum; + + private decimal _emaValue; + private bool _emaInitialized; + + #endregion + #endregion #region Properties @@ -560,6 +607,134 @@ public decimal NegativeAlertFilter #endregion + #region Average Delta + + [Display(ResourceType = typeof(Strings), Name = nameof(Strings.ShowAverage), + GroupName = nameof(Strings.Average), Order = 400)] + [Tab(TabName = nameof(Strings.Visualization), TabOrder = 1, ResourceType = typeof(Strings))] + public bool ShowAverage + { + get => _showAverage; + set + { + if (_showAverage == value) + return; + + _showAverage = value; + _avgSeries.VisualType = value ? VisualMode.Line : VisualMode.Hide; + RecalculateValues(); + RedrawChart(); + } + } + + [Display(ResourceType = typeof(Strings), Name = nameof(Strings.AveragePeriod), + GroupName = nameof(Strings.Average), Order = 410)] + [Range(1, 1000)] + [Tab(TabName = nameof(Strings.Visualization), TabOrder = 1, ResourceType = typeof(Strings))] + public int AveragePeriod + { + get => _averagePeriod; + set + { + if (value < 1) value = 1; + if (_averagePeriod == value) + return; + + _averagePeriod = value; + RecalculateValues(); + RedrawChart(); + } + } + + [Display(ResourceType = typeof(Strings), Name = nameof(Strings.CalculationMode), + GroupName = nameof(Strings.Average), Order = 420)] + [Tab(TabName = nameof(Strings.Visualization), TabOrder = 1, ResourceType = typeof(Strings))] + public AverageMode AvgMode + { + get => _avgMode; + set + { + if (_avgMode == value) + return; + + _avgMode = value; + RecalculateValues(); + RedrawChart(); + } + } + + [Display(Name = "Color mode", GroupName = nameof(Strings.Average), Order = 425)] + [Tab(TabName = nameof(Strings.Visualization), TabOrder = 1, ResourceType = typeof(Strings))] + public AverageColorMode AvgColorMode + { + get => _avgColorMode; + set + { + if (_avgColorMode == value) + return; + + _avgColorMode = value; + RecalculateValues(); + RedrawChart(); + } + } + + [Display(Name = "Base color", GroupName = nameof(Strings.Average), Order = 430)] + [Tab(TabName = nameof(Strings.Visualization), TabOrder = 1, ResourceType = typeof(Strings))] + public CrossColor AverageColor + { + get => _averageColor.Convert(); + set + { + _averageColor = value.Convert(); + _avgSeries.Color = value; + RedrawChart(); + } + } + + [Display(Name = "Slope up color", GroupName = nameof(Strings.Average), Order = 431)] + [Tab(TabName = nameof(Strings.Visualization), TabOrder = 1, ResourceType = typeof(Strings))] + public CrossColor AvgSlopeUpColor + { + get => _avgSlopeUpColor.Convert(); + set + { + _avgSlopeUpColor = value.Convert(); + RedrawChart(); + } + } + + [Display(Name = "Slope down color", GroupName = nameof(Strings.Average), Order = 432)] + [Tab(TabName = nameof(Strings.Visualization), TabOrder = 1, ResourceType = typeof(Strings))] + public CrossColor AvgSlopeDownColor + { + get => _avgSlopeDownColor.Convert(); + set + { + _avgSlopeDownColor = value.Convert(); + RedrawChart(); + } + } + + [Display(ResourceType = typeof(Strings), Name = nameof(Strings.Width), + GroupName = nameof(Strings.Average), Order = 440)] + [Range(1, 10)] + [Tab(TabName = nameof(Strings.Visualization), TabOrder = 1, ResourceType = typeof(Strings))] + public int AverageWidth + { + get => _avgSeries.Width; + set + { + if (_avgSeries.Width == value) + return; + + _avgSeries.Width = value; + RedrawChart(); + } + } + + #endregion + #endregion #region ctor @@ -586,6 +761,7 @@ public Delta() DataSeries.Add(_divergenceDownCandles); DataSeries.Add(_absorptionCandles); + DataSeries.Add(_avgSeries); UpAlert.PropertyChanged += OnUpAlertChanged; DownAlert.PropertyChanged += OnDownAlertChanged; @@ -906,6 +1082,67 @@ protected override void OnCalculate(int bar, decimal value) } } + // --- Average delta (SMA/EMA) --- + if (bar == 0) + { + _smaWindow.Clear(); + _smaSum = 0m; + _emaInitialized = false; + _emaValue = 0m; + } + + if (_showAverage) + { + decimal avgVal; + + if (_avgMode == AverageMode.Sma) + { + _smaWindow.Enqueue(deltaValue); + _smaSum += deltaValue; + + while (_smaWindow.Count > _averagePeriod) + _smaSum -= _smaWindow.Dequeue(); + + avgVal = _smaSum / Math.Max(1, _smaWindow.Count); + } + else // EMA + { + if (!_emaInitialized) + { + _emaValue = deltaValue; + _emaInitialized = true; + } + else + { + var k = 2m / (_averagePeriod + 1m); + _emaValue = (deltaValue - _emaValue) * k + _emaValue; + } + + avgVal = _emaValue; + } + + _avgSeries[bar] = avgVal; + + // Color logic + if (_avgColorMode == AverageColorMode.Fixed) + { + _avgSeries.Colors[bar] = _averageColor; + } + else if (_avgColorMode == AverageColorMode.ZeroCross) + { + _avgSeries.Colors[bar] = avgVal >= 0 ? _avgSlopeUpColor : _avgSlopeDownColor; + } + else // Slope + { + var prevAvg = bar > 0 ? _avgSeries[bar - 1] : avgVal; + _avgSeries.Colors[bar] = avgVal >= prevAvg ? _avgSlopeUpColor : _avgSlopeDownColor; + } + } + else + { + _avgSeries[bar] = 0m; + } + _prevDeltaValue = deltaValue; if (Absorption.Enabled) From 840dc4411d9981884e4ce33f6d0a25362e78cfc1 Mon Sep 17 00:00:00 2001 From: AlbertoAmadorBelchistim Date: Sat, 19 Sep 2026 22:00:04 +0200 Subject: [PATCH 2/2] fix(Delta): make the average delta line independent of intrabar updates OnCalculate runs repeatedly for the forming bar. The SMA enqueued every call as a new sample and the EMA advanced its state on every call, so the live values depended on the number of updates a bar received. Both averages are now computed from per-bar values only: the input is stored by bar index, the SMA keeps a running sum per bar and the EMA starts from the stored value of the previous bar. Recalculating the forming bar any number of times gives the same result as a full historical recalculation. The inline UI strings are replaced with existing localization keys. The color options follow the built-in SMA/EMA indicators (Colored direction with bullish and bearish colors); the line color and width are set on the series itself. --- Technical/Delta.cs | 214 ++++++++++++++++++--------------------------- 1 file changed, 86 insertions(+), 128 deletions(-) diff --git a/Technical/Delta.cs b/Technical/Delta.cs index c4e1c2e4a..1ee47c2c7 100644 --- a/Technical/Delta.cs +++ b/Technical/Delta.cs @@ -84,17 +84,6 @@ public enum AverageMode Ema = 1 } - [Serializable] - public enum AverageColorMode - { - [Display(Name = "Fixed")] - Fixed = 0, - [Display(Name = "Zero cross")] - ZeroCross = 1, - [Display(Name = "Slope")] - Slope = 2 - } - #endregion #region Fields @@ -226,7 +215,7 @@ public enum AverageColorMode #region Fields (average delta) - private readonly ValueDataSeries _avgSeries = new("AverageDelta", "Average") + private readonly ValueDataSeries _avgSeries = new("AverageDelta", Strings.Average) { VisualType = VisualMode.Hide, Width = 2, @@ -234,20 +223,19 @@ public enum AverageColorMode ShowCurrentValue = false }; + // Per-bar input of the average. Values are written by bar index, so repeated + // OnCalculate calls for the forming bar overwrite the sample instead of adding one. + private readonly ValueDataSeries _avgInput = new("AverageDeltaInput") { IsHidden = true }; + + // Running SMA sum per bar: _avgSum[bar] = _avgSum[bar - 1] + input[bar] - input[bar - period]. + private readonly ValueDataSeries _avgSum = new("AverageDeltaSum") { IsHidden = true }; + private bool _showAverage; private int _averagePeriod = 20; private AverageMode _avgMode = AverageMode.Sma; - private AverageColorMode _avgColorMode = AverageColorMode.Fixed; - - private Color _averageColor = Color.FromArgb(255, 60, 120, 240); - private Color _avgSlopeUpColor = Color.Green; - private Color _avgSlopeDownColor = Color.Red; - - private readonly Queue _smaWindow = new(); - private decimal _smaSum; - - private decimal _emaValue; - private bool _emaInitialized; + private bool _avgColoredDirection; + private Color _avgBullishColor = Color.Green; + private Color _avgBearishColor = Color.Red; #endregion @@ -663,73 +651,48 @@ public AverageMode AvgMode } } - [Display(Name = "Color mode", GroupName = nameof(Strings.Average), Order = 425)] + [Display(ResourceType = typeof(Strings), Name = nameof(Strings.ColoredDirection), + GroupName = nameof(Strings.Average), Order = 430)] [Tab(TabName = nameof(Strings.Visualization), TabOrder = 1, ResourceType = typeof(Strings))] - public AverageColorMode AvgColorMode + public bool AvgColoredDirection { - get => _avgColorMode; + get => _avgColoredDirection; set { - if (_avgColorMode == value) + if (_avgColoredDirection == value) return; - _avgColorMode = value; - RecalculateValues(); - RedrawChart(); - } - } + _avgColoredDirection = value; - [Display(Name = "Base color", GroupName = nameof(Strings.Average), Order = 430)] - [Tab(TabName = nameof(Strings.Visualization), TabOrder = 1, ResourceType = typeof(Strings))] - public CrossColor AverageColor - { - get => _averageColor.Convert(); - set - { - _averageColor = value.Convert(); - _avgSeries.Color = value; - RedrawChart(); - } - } - - [Display(Name = "Slope up color", GroupName = nameof(Strings.Average), Order = 431)] - [Tab(TabName = nameof(Strings.Visualization), TabOrder = 1, ResourceType = typeof(Strings))] - public CrossColor AvgSlopeUpColor - { - get => _avgSlopeUpColor.Convert(); - set - { - _avgSlopeUpColor = value.Convert(); + // Per-bar colors cannot be unset individually; a recalculation clears them. + RecalculateValues(); RedrawChart(); } } - [Display(Name = "Slope down color", GroupName = nameof(Strings.Average), Order = 432)] + [Display(ResourceType = typeof(Strings), Name = nameof(Strings.BullishColor), + GroupName = nameof(Strings.Average), Order = 431)] [Tab(TabName = nameof(Strings.Visualization), TabOrder = 1, ResourceType = typeof(Strings))] - public CrossColor AvgSlopeDownColor + public CrossColor AvgBullishColor { - get => _avgSlopeDownColor.Convert(); + get => _avgBullishColor.Convert(); set { - _avgSlopeDownColor = value.Convert(); - RedrawChart(); + _avgBullishColor = value.Convert(); + RecolorAverage(); } } - [Display(ResourceType = typeof(Strings), Name = nameof(Strings.Width), - GroupName = nameof(Strings.Average), Order = 440)] - [Range(1, 10)] + [Display(ResourceType = typeof(Strings), Name = nameof(Strings.BearishColor), + GroupName = nameof(Strings.Average), Order = 432)] [Tab(TabName = nameof(Strings.Visualization), TabOrder = 1, ResourceType = typeof(Strings))] - public int AverageWidth + public CrossColor AvgBearishColor { - get => _avgSeries.Width; + get => _avgBearishColor.Convert(); set { - if (_avgSeries.Width == value) - return; - - _avgSeries.Width = value; - RedrawChart(); + _avgBearishColor = value.Convert(); + RecolorAverage(); } } @@ -1082,66 +1045,7 @@ protected override void OnCalculate(int bar, decimal value) } } - // --- Average delta (SMA/EMA) --- - if (bar == 0) - { - _smaWindow.Clear(); - _smaSum = 0m; - _emaInitialized = false; - _emaValue = 0m; - } - - if (_showAverage) - { - decimal avgVal; - - if (_avgMode == AverageMode.Sma) - { - _smaWindow.Enqueue(deltaValue); - _smaSum += deltaValue; - - while (_smaWindow.Count > _averagePeriod) - _smaSum -= _smaWindow.Dequeue(); - - avgVal = _smaSum / Math.Max(1, _smaWindow.Count); - } - else // EMA - { - if (!_emaInitialized) - { - _emaValue = deltaValue; - _emaInitialized = true; - } - else - { - var k = 2m / (_averagePeriod + 1m); - _emaValue = (deltaValue - _emaValue) * k + _emaValue; - } - - avgVal = _emaValue; - } - - _avgSeries[bar] = avgVal; - - // Color logic - if (_avgColorMode == AverageColorMode.Fixed) - { - _avgSeries.Colors[bar] = _averageColor; - } - else if (_avgColorMode == AverageColorMode.ZeroCross) - { - _avgSeries.Colors[bar] = avgVal >= 0 ? _avgSlopeUpColor : _avgSlopeDownColor; - } - else // Slope - { - var prevAvg = bar > 0 ? _avgSeries[bar - 1] : avgVal; - _avgSeries.Colors[bar] = avgVal >= prevAvg ? _avgSlopeUpColor : _avgSlopeDownColor; - } - } - else - { - _avgSeries[bar] = 0m; - } + CalculateAverage(bar, deltaValue); _prevDeltaValue = deltaValue; @@ -1223,6 +1127,60 @@ protected override void OnCalculate(int bar, decimal value) #region Private methods + private void CalculateAverage(int bar, decimal deltaValue) + { + if (!_showAverage) + { + _avgSeries[bar] = 0m; + return; + } + + // Everything below depends only on the values of this bar and the stored results of + // previous bars, so recalculating the forming bar any number of times gives the same + // value as a full historical recalculation. + _avgInput[bar] = deltaValue; + + decimal average; + + if (_avgMode == AverageMode.Sma) + { + var sum = (bar > 0 ? _avgSum[bar - 1] : 0m) + deltaValue; + + if (bar >= _averagePeriod) + sum -= _avgInput[bar - _averagePeriod]; + + _avgSum[bar] = sum; + average = sum / Math.Min(_averagePeriod, bar + 1); + } + else + { + var k = 2m / (_averagePeriod + 1m); + average = bar == 0 ? deltaValue : _avgSeries[bar - 1] + (deltaValue - _avgSeries[bar - 1]) * k; + } + + _avgSeries[bar] = average; + SetAverageColor(bar); + } + + private void SetAverageColor(int bar) + { + // Without direction coloring no per-bar color is set, so the line keeps the series color. + if (!_avgColoredDirection || bar == 0) + return; + + _avgSeries.Colors[bar] = _avgSeries[bar] >= _avgSeries[bar - 1] ? _avgBullishColor : _avgBearishColor; + } + + private void RecolorAverage() + { + if (_showAverage && _avgColoredDirection) + { + for (var bar = 0; bar < CurrentBar; bar++) + SetAverageColor(bar); + } + + RedrawChart(); + } private int GetMinWidth(RenderContext context, int startBar, int endBar) {