Add TRAMA implementation and comprehensive tests

- Implemented the TRAMA (Trend Regularity Adaptive Moving Average) class with adaptive EMA logic.
- Added unit tests for TRAMA functionality, including constructor validation, basic calculations, state management, and robustness checks.
- Created validation tests to ensure consistency across different modes of operation (streaming, batch, and static calculations).
- Enhanced documentation for TRAMA, including performance profiles and quality metrics.
- Updated workspace configuration by removing unnecessary folder references.
This commit is contained in:
Miha Kralj
2026-02-21 20:45:38 -08:00
parent 90d5638008
commit 7253f61299
199 changed files with 29577 additions and 234 deletions
-1
View File
@@ -8,7 +8,6 @@ Signal processing filters adapted for financial time series. These are not indic
| Indicator | Full Name | Description |
| :--- | :--- | :--- |
| [AGC](agc/Agc.md) | Ehlers Automatic Gain Control | Ehlers. Amplitude normalization via exponential peak tracking. |
| [ALAGUERRE](alaguerre/ALaguerre.md) | Ehlers Adaptive Laguerre Filter | Ehlers. Variable-alpha Laguerre from tracking-error normalization. |
| [BAXTERKING](baxterking/BaxterKing.md) | Baxter-King Band-Pass Filter | Symmetric FIR band-pass. Ideal for business cycle extraction. |
| [CFITZ](cfitz/Cfitz.md) | Christiano-Fitzgerald Filter | Asymmetric full-sample band-pass. Optimal under random-walk assumption. |
-127
View File
@@ -1,127 +0,0 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class AgcIndicatorTests
{
[Fact]
public void AgcIndicator_Constructor_SetsDefaults()
{
var indicator = new AgcIndicator();
Assert.Equal(0.991, indicator.Decay);
Assert.Equal(SourceType.Close, indicator.Source);
Assert.True(indicator.ShowColdValues);
Assert.Equal("AGC - Ehlers Automatic Gain Control", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void AgcIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new AgcIndicator { Decay = 0.991 };
Assert.Equal(0, AgcIndicator.MinHistoryDepths);
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void AgcIndicator_ShortName_IncludesParameters()
{
var indicator = new AgcIndicator { Decay = 0.991 };
Assert.Contains("AGC", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("0.991", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void AgcIndicator_Initialize_CreatesInternalAgc()
{
var indicator = new AgcIndicator { Decay = 0.991 };
indicator.Initialize();
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void AgcIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new AgcIndicator { Decay = 0.991 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
Assert.Equal(1, indicator.LinesSeries[0].Count);
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
}
[Fact]
public void AgcIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new AgcIndicator { Decay = 0.991 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(2, indicator.LinesSeries[0].Count);
}
[Fact]
public void AgcIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
{
var indicator = new AgcIndicator { Decay = 0.991 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
double firstValue = indicator.LinesSeries[0].GetValue(0);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
double secondValue = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(firstValue));
Assert.True(double.IsFinite(secondValue));
}
[Fact]
public void AgcIndicator_DifferentSourceTypes_Work()
{
var sources = new[] { SourceType.Open, SourceType.High, SourceType.Low, SourceType.Close, SourceType.HL2, SourceType.HLC3 };
foreach (var source in sources)
{
var indicator = new AgcIndicator { Decay = 0.991, Source = source };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 110, 90, 105);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)),
$"Source {source} should produce finite value");
}
}
[Fact]
public void AgcIndicator_Parameters_CanBeChanged()
{
var indicator = new AgcIndicator { Decay = 0.991 };
Assert.Equal(0.991, indicator.Decay);
indicator.Decay = 0.95;
Assert.Equal(0.95, indicator.Decay);
}
}
-61
View File
@@ -1,61 +0,0 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class AgcIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Decay", sortIndex: 1, 0.9, 0.9999, 0.001, 3)]
public double Decay { get; set; } = 0.991;
[IndicatorExtensions.DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Agc _agc = null!;
private Roofing _roofing = null!;
private readonly LineSeries _series;
private string _sourceName = null!;
private Func<IHistoryItem, double> _priceSelector = null!;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"AGC {Decay:F3}:{_sourceName}";
public AgcIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "AGC - Ehlers Automatic Gain Control";
Description = "Ehlers Automatic Gain Control: amplitude normalization via exponential peak tracking, applied after Roofing filter";
_series = new LineSeries(name: $"AGC {Decay:F3}", color: Color.Blue, width: 2, style: LineStyle.Solid);
AddLineSeries(_series);
}
protected override void OnInit()
{
_priceSelector = Source.GetPriceSelector();
_sourceName = Source.ToString();
_roofing = new Roofing(48, 10);
_agc = new Agc(Decay);
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
bool isNew = args.IsNewBar();
var item = HistoricalData[Count - 1, SeekOriginHistory.Begin];
double price = _priceSelector(item);
// First apply roofing filter to get oscillating signal, then normalize with AGC
double filtered = _roofing.Update(new TValue(item.TimeLeft.Ticks, price), isNew).Value;
double value = _agc.Update(new TValue(item.TimeLeft.Ticks, filtered), isNew).Value;
_series.SetValue(value, _agc.IsHot, ShowColdValues);
}
}
-430
View File
@@ -1,430 +0,0 @@
namespace QuanTAlib;
public class AgcTests
{
// Helper: generate a sine wave that oscillates around zero
private static TSeries MakeSineWave(int count, double amplitude = 1.0, double period = 20.0)
{
var series = new TSeries();
DateTime t = DateTime.UtcNow;
for (int i = 0; i < count; i++)
{
double val = amplitude * Math.Sin(2.0 * Math.PI * i / period);
series.Add(new TValue(t.AddMinutes(i), val));
}
return series;
}
// --- A) Constructor Validation ---
[Fact]
public void Constructor_ValidatesDecay_TooLow()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new Agc(decay: 0.0));
Assert.Throws<ArgumentOutOfRangeException>(() => new Agc(decay: -0.5));
}
[Fact]
public void Constructor_ValidatesDecay_TooHigh()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new Agc(decay: 1.0));
Assert.Throws<ArgumentOutOfRangeException>(() => new Agc(decay: 1.5));
}
[Fact]
public void Constructor_SetsName()
{
var ind = new Agc(0.991);
Assert.Equal("AGC(0.991)", ind.Name);
}
[Fact]
public void Constructor_SetsWarmupPeriod()
{
var ind = new Agc(0.991);
Assert.Equal(1, ind.WarmupPeriod);
}
[Fact]
public void Constructor_DefaultParameters()
{
var ind = new Agc();
Assert.Equal(0.991, ind.Decay);
}
// --- B) Basic Calculation ---
[Fact]
public void Calc_ReturnsFiniteValue()
{
var ind = new Agc();
// Feed an oscillating value (not raw price!)
var result = ind.Update(new TValue(DateTime.UtcNow, 0.5));
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Calc_PropertiesAccessible()
{
var ind = new Agc();
ind.Update(new TValue(DateTime.UtcNow, 0.5));
Assert.True(double.IsFinite(ind.Last.Value));
Assert.True(ind.IsHot);
Assert.Equal("AGC(0.991)", ind.Name);
_ = ind.IsNew;
}
[Fact]
public void SineInput_OutputBounded()
{
// A pure sine wave fed through AGC should produce output in [-1, +1]
var ind = new Agc(0.991);
var sine = MakeSineWave(500);
foreach (var item in sine)
{
var result = ind.Update(item);
Assert.True(result.Value >= -1.0001 && result.Value <= 1.0001,
$"AGC output {result.Value} exceeds [-1, +1] bounds");
}
}
[Fact]
public void ConstantInput_ReturnsOne()
{
// Constant positive input → peak = val → output = val/val = 1.0
var ind = new Agc(0.991);
double lastVal = 0;
for (int i = 0; i < 200; i++)
{
lastVal = ind.Update(new TValue(DateTime.UtcNow, 5.0)).Value;
}
Assert.Equal(1.0, lastVal, 1e-6);
}
[Fact]
public void ZeroInput_ReturnsZero()
{
// Zero input → output = 0 / peak = 0
var ind = new Agc(0.991);
ind.Update(new TValue(DateTime.UtcNow, 1.0)); // prime with non-zero
double val = ind.Update(new TValue(DateTime.UtcNow, 0.0)).Value;
Assert.Equal(0.0, val, 1e-10);
}
// --- C) State + Bar Correction ---
[Fact]
public void Calc_IsNew_AcceptsParameter()
{
var ind = new Agc();
var sine = MakeSineWave(20);
foreach (var item in sine)
{
ind.Update(item);
}
double val1 = ind.Last.Value;
ind.Update(new TValue(DateTime.UtcNow, 0.75), isNew: false);
double val2 = ind.Last.Value;
Assert.NotEqual(val1, val2);
}
[Fact]
public void Calc_IsNew_False_UpdatesValue()
{
var ind = new Agc();
ind.Update(new TValue(DateTime.UtcNow, 0.5), isNew: true);
ind.Update(new TValue(DateTime.UtcNow, 0.8), isNew: true);
double val1 = ind.Last.Value;
ind.Update(new TValue(DateTime.UtcNow, 0.3), isNew: false);
double val2 = ind.Last.Value;
Assert.NotEqual(val1, val2);
}
[Fact]
public void IterativeCorrections_RestoreToOriginalState()
{
var ind = new Agc();
var sine = MakeSineWave(50);
for (int i = 0; i < sine.Count; i++)
{
ind.Update(sine[i]);
}
double originalValue = ind.Last.Value;
// Feed corrections with isNew=false
ind.Update(new TValue(DateTime.UtcNow, 0.1), isNew: false);
ind.Update(new TValue(DateTime.UtcNow, 0.9), isNew: false);
ind.Update(new TValue(DateTime.UtcNow, -0.5), isNew: false);
// Restore with original last value
ind.Update(sine[^1], isNew: false);
double restoredValue = ind.Last.Value;
Assert.Equal(originalValue, restoredValue, 10);
}
[Fact]
public void Reset_ClearsState()
{
var ind = new Agc();
var sine = MakeSineWave(50);
foreach (var item in sine)
{
ind.Update(item);
}
ind.Reset();
var ind2 = new Agc();
var result1 = ind.Update(new TValue(DateTime.UtcNow, 0.5));
var result2 = ind2.Update(new TValue(DateTime.UtcNow, 0.5));
Assert.Equal(result2.Value, result1.Value, 10);
}
// --- D) Warmup/Convergence ---
[Fact]
public void IsHot_TrueAfterFirstUpdate()
{
var ind = new Agc();
Assert.False(ind.IsHot); // No data yet
ind.Update(new TValue(DateTime.UtcNow, 0.5));
Assert.True(ind.IsHot); // One bar is enough
}
// --- E) Robustness ---
[Fact]
public void NaN_Input_UsesLastValidValue()
{
var ind = new Agc();
ind.Update(new TValue(DateTime.UtcNow, 0.5));
ind.Update(new TValue(DateTime.UtcNow, 0.8));
var result = ind.Update(new TValue(DateTime.UtcNow, double.NaN));
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Infinity_Input_UsesLastValidValue()
{
var ind = new Agc();
ind.Update(new TValue(DateTime.UtcNow, 0.5));
ind.Update(new TValue(DateTime.UtcNow, 0.8));
var result = ind.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
Assert.True(double.IsFinite(result.Value));
var result2 = ind.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity));
Assert.True(double.IsFinite(result2.Value));
}
[Fact]
public void MultipleNaN_ContinuesWithLastValid()
{
var ind = new Agc();
ind.Update(new TValue(DateTime.UtcNow, 0.5));
ind.Update(new TValue(DateTime.UtcNow, 0.8));
for (int i = 0; i < 10; i++)
{
var result = ind.Update(new TValue(DateTime.UtcNow, double.NaN));
Assert.True(double.IsFinite(result.Value));
}
}
[Fact]
public void BatchCalc_HandlesNaN()
{
double[] input = [0.5, 0.8, double.NaN, -0.3, double.NaN, 0.6];
double[] output = new double[input.Length];
Agc.Batch(input, output, 0.991);
for (int i = 0; i < output.Length; i++)
{
Assert.True(double.IsFinite(output[i]), $"Output[{i}] should be finite");
}
}
// --- F) Consistency ---
[Fact]
public void AllModes_ProduceSameResult()
{
const double decay = 0.991;
var sine = MakeSineWave(200);
// 1. Span Mode
double[] spanOutput = new double[sine.Count];
Agc.Batch(sine.Values.ToArray(), spanOutput, decay);
// 2. TSeries Batch Mode
var agcBatch = new Agc(decay);
var batchResult = agcBatch.Update(sine);
// 3. Streaming Mode
var agcStream = new Agc(decay);
var streamResults = new List<double>();
foreach (var item in sine)
{
streamResults.Add(agcStream.Update(item).Value);
}
// 4. Eventing Mode
var pubSource = new TSeries();
var agcEvent = new Agc(pubSource, decay);
for (int i = 0; i < sine.Count; i++)
{
pubSource.Add(sine[i]);
}
// Assert all modes match
for (int i = 0; i < sine.Count; i++)
{
Assert.Equal(spanOutput[i], batchResult[i].Value, 1e-9);
Assert.Equal(spanOutput[i], streamResults[i], 1e-9);
}
Assert.Equal(spanOutput[^1], agcEvent.Last.Value, 1e-9);
}
// --- G) Span API ---
[Fact]
public void SpanCalc_ValidatesLength()
{
double[] source = new double[10];
double[] output = new double[5]; // Mismatched!
Assert.Throws<ArgumentException>(() => Agc.Batch(source, output));
}
[Fact]
public void SpanCalc_SineInput_OutputBounded()
{
double[] input = new double[500];
for (int i = 0; i < input.Length; i++)
{
input[i] = Math.Sin(2.0 * Math.PI * i / 20.0);
}
double[] output = new double[500];
Agc.Batch(input, output, 0.991);
for (int i = 0; i < output.Length; i++)
{
Assert.True(output[i] >= -1.0001 && output[i] <= 1.0001,
$"Output[{i}] = {output[i]} exceeds [-1, +1] bounds");
}
}
[Fact]
public void SpanCalc_MatchesTSeriesCalc()
{
var sine = MakeSineWave(200);
// Span
double[] spanOutput = new double[sine.Count];
Agc.Batch(sine.Values.ToArray(), spanOutput, 0.991);
// TSeries
var ind = new Agc(0.991);
var tseriesResult = ind.Update(sine);
for (int i = 0; i < sine.Count; i++)
{
Assert.Equal(spanOutput[i], tseriesResult[i].Value, 1e-9);
}
}
// --- H) Chainability ---
[Fact]
public void Pub_FiresOnUpdate()
{
var ind = new Agc();
int fireCount = 0;
ind.Pub += (object? _, in TValueEventArgs _) => fireCount++;
ind.Update(new TValue(DateTime.UtcNow, 0.5));
ind.Update(new TValue(DateTime.UtcNow, 0.8));
Assert.Equal(2, fireCount);
}
[Fact]
public void EventChaining_Works()
{
var source = new TSeries();
var ind = new Agc(source);
source.Add(new TValue(DateTime.UtcNow, 0.5));
source.Add(new TValue(DateTime.UtcNow, 0.8));
Assert.True(double.IsFinite(ind.Last.Value));
}
// --- Additional ---
[Fact]
public void DifferentDecays_ProduceDifferentResults()
{
var sine = MakeSineWave(200);
var ind1 = new Agc(0.991);
var ind2 = new Agc(0.95);
foreach (var item in sine)
{
ind1.Update(item);
ind2.Update(item);
}
Assert.NotEqual(ind1.Last.Value, ind2.Last.Value);
}
[Fact]
public void LargeDataset_DoesNotThrow()
{
double[] input = new double[10000];
for (int i = 0; i < input.Length; i++)
{
input[i] = Math.Sin(2.0 * Math.PI * i / 20.0);
}
double[] output = new double[input.Length];
Agc.Batch(input, output, 0.991);
Assert.True(double.IsFinite(output[^1]));
}
[Fact]
public void NegativeInput_ProducesNegativeOutput()
{
var ind = new Agc();
ind.Update(new TValue(DateTime.UtcNow, 1.0)); // prime peak
double val = ind.Update(new TValue(DateTime.UtcNow, -0.5)).Value;
Assert.True(val < 0, $"Negative input should produce negative output, got {val}");
}
[Fact]
public void Dispose_UnsubscribesFromSource()
{
var source = new TSeries();
var ind = new Agc(source);
source.Add(new TValue(DateTime.UtcNow, 0.5));
Assert.True(double.IsFinite(ind.Last.Value));
ind.Dispose();
// After dispose, further adds should not affect ind
double lastBefore = ind.Last.Value;
source.Add(new TValue(DateTime.UtcNow, 999.0));
Assert.Equal(lastBefore, ind.Last.Value, 10);
}
}
-196
View File
@@ -1,196 +0,0 @@
using System;
using System.Linq;
using Xunit;
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for the AGC (Automatic Gain Control) filter.
/// Since AGC is a proprietary Ehlers normalizer, no external library implementations exist.
/// Validation uses self-consistency: bounded output, normalization behavior, mode consistency, and determinism.
/// </summary>
public class AgcValidationTests
{
[Fact]
public void Validate_SineWave_NormalizesToUnitAmplitude()
{
// A pure sine wave (amplitude=1) should normalize to ~1 peak after warmup
const int T = 1000;
double[] sine = new double[T];
for (int i = 0; i < T; i++)
{
sine[i] = Math.Sin(2.0 * Math.PI * i / 20.0);
}
double[] output = new double[T];
Agc.Batch(sine, output, 0.991);
// After warmup, output peaks should be close to ±1
double maxAbs = 0;
for (int i = T - 100; i < T; i++)
{
maxAbs = Math.Max(maxAbs, Math.Abs(output[i]));
}
Assert.True(maxAbs >= 0.95 && maxAbs <= 1.0001,
$"Normalized sine should peak near ±1, got max |output| = {maxAbs}");
}
[Fact]
public void Validate_GrowingAmplitude_TracksWithinBounds()
{
// Sine wave with growing amplitude — AGC should keep output bounded
const int T = 1000;
double[] input = new double[T];
for (int i = 0; i < T; i++)
{
double amplitude = 1.0 + i * 0.01; // grows from 1 to 11
input[i] = amplitude * Math.Sin(2.0 * Math.PI * i / 20.0);
}
double[] output = new double[T];
Agc.Batch(input, output, 0.991);
for (int i = 0; i < T; i++)
{
Assert.True(output[i] >= -1.0001 && output[i] <= 1.0001,
$"Output[{i}] = {output[i]} exceeds [-1, +1] bounds");
}
}
[Fact]
public void Validate_StreamingMatchesSpan()
{
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
var data = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
// Use roofing to create oscillating input
double[] prices = data.Close.Values.ToArray();
double[] filtered = new double[prices.Length];
Roofing.Batch(prices, filtered, 48, 10);
// Span mode
double[] spanOut = new double[filtered.Length];
Agc.Batch(filtered, spanOut, 0.991);
// Streaming mode
var ind = new Agc(0.991);
double[] streamOut = new double[filtered.Length];
for (int i = 0; i < filtered.Length; i++)
{
streamOut[i] = ind.Update(new TValue(DateTime.UtcNow, filtered[i])).Value;
}
for (int i = 0; i < filtered.Length; i++)
{
Assert.Equal(spanOut[i], streamOut[i], 1e-9);
}
}
[Fact]
public void Validate_Deterministic()
{
double[] input = new double[500];
for (int i = 0; i < input.Length; i++)
{
input[i] = Math.Sin(2.0 * Math.PI * i / 25.0) * (1.0 + 0.3 * Math.Sin(2.0 * Math.PI * i / 100.0));
}
double[] out1 = new double[input.Length];
double[] out2 = new double[input.Length];
Agc.Batch(input, out1, 0.991);
Agc.Batch(input, out2, 0.991);
for (int i = 0; i < input.Length; i++)
{
Assert.Equal(out1[i], out2[i], 15);
}
}
[Fact]
public void Validate_DecayingAmplitude_OutputGrows()
{
// When amplitude decays, AGC peak decays too, so normalized output stays near ±1
const int T = 1000;
double[] input = new double[T];
for (int i = 0; i < T; i++)
{
double amplitude = 10.0 * Math.Exp(-i * 0.005); // exponentially decaying
input[i] = amplitude * Math.Sin(2.0 * Math.PI * i / 20.0);
}
double[] output = new double[T];
Agc.Batch(input, output, 0.991);
// Output should still oscillate near ±1 in the tail (AGC adapts)
double maxTail = 0;
for (int i = T - 100; i < T; i++)
{
maxTail = Math.Max(maxTail, Math.Abs(output[i]));
}
Assert.True(maxTail > 0.5, $"Decaying amplitude should still produce sizable normalized output, got max = {maxTail}");
}
[Fact]
public void Validate_LargeDataset_Stable()
{
double[] input = new double[10000];
for (int i = 0; i < input.Length; i++)
{
input[i] = Math.Sin(2.0 * Math.PI * i / 20.0);
}
double[] output = new double[input.Length];
Agc.Batch(input, output, 0.991);
for (int i = 0; i < output.Length; i++)
{
Assert.True(double.IsFinite(output[i]), $"Output[{i}] is not finite: {output[i]}");
}
}
[Fact]
public void Validate_NaN_Batch_Safe()
{
double[] input = new double[100];
for (int i = 0; i < 100; i++)
{
input[i] = i % 7 == 0 ? double.NaN : Math.Sin(2.0 * Math.PI * i / 20.0);
}
double[] output = new double[100];
Agc.Batch(input, output, 0.991);
for (int i = 0; i < output.Length; i++)
{
Assert.True(double.IsFinite(output[i]), $"Output[{i}] should be finite with NaN input");
}
}
[Fact]
public void Validate_DifferentDecays_ProduceDifferentOutput()
{
double[] input = new double[500];
for (int i = 0; i < input.Length; i++)
{
input[i] = Math.Sin(2.0 * Math.PI * i / 20.0);
}
double[] out1 = new double[input.Length];
double[] out2 = new double[input.Length];
Agc.Batch(input, out1, 0.991);
Agc.Batch(input, out2, 0.95);
bool anyDifferent = false;
for (int i = 50; i < input.Length; i++)
{
if (Math.Abs(out1[i] - out2[i]) > 1e-10)
{
anyDifferent = true;
break;
}
}
Assert.True(anyDifferent, "Different decay parameters should produce different output");
}
}
-236
View File
@@ -1,236 +0,0 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// AGC: Automatic Gain Control (Ehlers)
/// Amplitude normalization via exponential peak tracking. Normalizes any oscillating
/// input signal to the [-1, +1] range by dividing by a decaying running peak.
/// </summary>
/// <remarks>
/// The algorithm is based on a Pine Script implementation:
/// https://github.com/mihakralj/pinescript/blob/main/indicators/filters/agc.md
///
/// Key properties:
/// - Pure normalizer: does NOT contain an internal filter stage
/// - Input should oscillate around zero (use after a bandpass/roofing/SSF filter)
/// - Peak decays exponentially each bar (decay=0.991 ≈ 110-bar half-life)
/// - Peak ratchets up instantly when |input| exceeds decayed peak
/// - Output bounded to [-1, +1] for well-behaved oscillating inputs
///
/// Complexity: O(1) — one multiply, one compare, one divide per bar
/// </remarks>
[SkipLocalsInit]
public sealed class Agc : AbstractBase
{
private readonly double _decay;
private ITValuePublisher? _publisher;
private TValuePublishedHandler? _handler;
private bool _isNew;
[StructLayout(LayoutKind.Auto)]
private record struct State
{
public double Peak;
public double LastValid;
public int Count;
}
private State _state;
private State _p_state;
/// <summary>
/// Peak decay factor per bar. Controls how quickly the normalizer adapts
/// to decreasing amplitude. 0.991 ≈ 110-bar half-life.
/// </summary>
public double Decay => _decay;
public bool IsNew => _isNew;
public override bool IsHot => _state.Count > 0;
public Agc(double decay = 0.991)
{
if (decay is <= 0.0 or >= 1.0)
{
throw new ArgumentOutOfRangeException(nameof(decay), "Decay must be between 0 and 1 exclusive.");
}
_decay = decay;
Name = $"AGC({decay:F3})";
WarmupPeriod = 1;
_state.Peak = 1e-10; // tiny positive to avoid div-by-zero on first bar
_state.LastValid = 0.0;
}
public Agc(ITValuePublisher source, double decay = 0.991) : this(decay)
{
_publisher = source;
_handler = Handle;
source.Pub += _handler;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void Handle(object? sender, in TValueEventArgs args)
{
Update(args.Value, args.IsNew);
}
public override TSeries Update(TSeries source)
{
if (source.Count == 0)
{
return [];
}
double[] values = source.Values.ToArray();
double[] results = new double[values.Length];
Batch(values, results, _decay);
TSeries output = [];
for (int i = 0; i < values.Length; i++)
{
output.Add(source[i].Time, results[i]);
}
// Sync internal state by replaying
Reset();
for (int i = 0; i < source.Count; i++)
{
Update(source[i]);
}
return output;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
_isNew = isNew;
if (isNew)
{
_p_state = _state;
}
else
{
_state = _p_state;
}
var s = _state;
// Handle bad data
double val = input.Value;
if (!double.IsFinite(val))
{
val = double.IsFinite(s.LastValid) ? s.LastValid : 0.0;
}
else
{
s.LastValid = val;
}
// Exponential peak decay
s.Peak *= _decay;
// Ratchet up when signal exceeds decayed peak
double absVal = Math.Abs(val);
if (absVal > s.Peak)
{
s.Peak = absVal;
}
// Normalize: output = val / peak
double result = s.Peak > 0.0 ? val / s.Peak : 0.0;
if (isNew)
{
s.Count++;
}
_state = s;
Last = new TValue(input.Time, result);
PubEvent(Last, isNew);
return Last;
}
public static TSeries Batch(TSeries source, double decay = 0.991)
{
var indicator = new Agc(decay);
return indicator.Update(source);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan<double> source, Span<double> output, double decay = 0.991)
{
if (source.Length != output.Length)
{
throw new ArgumentException("Source and output spans must be of the same length.", nameof(output));
}
double peak = 1e-10;
double lastValid = 0.0;
for (int i = 0; i < source.Length; i++)
{
double val = source[i];
if (!double.IsFinite(val))
{
val = lastValid;
}
else
{
lastValid = val;
}
// Decay peak
peak *= decay;
// Ratchet
double absVal = Math.Abs(val);
if (absVal > peak)
{
peak = absVal;
}
// Normalize
output[i] = peak > 0.0 ? val / peak : 0.0;
}
}
public override void Reset()
{
_state = default;
_state.Peak = 1e-10;
_state.LastValid = 0.0;
_p_state = default;
Last = default;
}
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
foreach (double val in source)
{
Update(new TValue(DateTime.UtcNow, val), isNew: true);
}
}
public static (TSeries Results, Agc Indicator) Calculate(TSeries source, double decay = 0.991)
{
var indicator = new Agc(decay);
TSeries results = indicator.Update(source);
return (results, indicator);
}
protected override void Dispose(bool disposing)
{
if (disposing && _publisher != null && _handler != null)
{
_publisher.Pub -= _handler;
_publisher = null;
_handler = null;
}
base.Dispose(disposing);
}
}
-146
View File
@@ -1,146 +0,0 @@
# AGC: Ehlers Automatic Gain Control
> "The purpose of the AGC is to normalize the amplitude of any indicator to unity." — John F. Ehlers, TASC January 2015
## Introduction
The Automatic Gain Control normalizes any oscillating signal to the \[-1, +1\] range through exponential peak tracking. Unlike fixed-window normalization (min-max scaling), AGC adapts continuously: the peak decays exponentially each bar and ratchets up instantly when the signal exceeds the current peak. The result is amplitude-independent comparison of filter outputs across instruments and timeframes. Ehlers introduced AGC as the final stage of his "Universal Oscillator" — a signal-processing chain that converts any price series into a bounded, zero-mean indicator suitable for threshold-based trading signals.
## Historical Context
Ehlers published the AGC technique in *Technical Analysis of Stocks & Commodities* (January 2015) as part of the "Universal Oscillator" article. The concept borrows directly from radio engineering, where automatic gain control circuits maintain constant output amplitude despite varying input signal strength. In the RF domain, AGC dates to the 1920s vacuum tube era and remains fundamental in modern receivers.
The key insight for technical analysis: oscillating filter outputs (bandpass, roofing, super smoother) have amplitude that varies with volatility. Without normalization, a fixed overbought/oversold threshold (say ±0.8) triggers at different volatility regimes. AGC eliminates this dependency by rescaling every signal to unit amplitude.
## Architecture and Physics
### 1. Exponential Peak Decay
The peak envelope decays exponentially each bar:
$$\text{Peak}_i = \delta \cdot \text{Peak}_{i-1}$$
where $\delta$ is the decay factor (default 0.991). The half-life in bars:
$$t_{1/2} = \frac{\ln 2}{\ln(1/\delta)} = \frac{0.6931}{\ln(1/0.991)} \approx 77 \text{ bars}$$
### 2. Peak Ratchet
When the absolute signal exceeds the decayed peak, the peak snaps to the new value:
$$\text{Peak}_i = \max(\delta \cdot \text{Peak}_{i-1},\; |\text{Signal}_i|)$$
This creates an asymmetric envelope: instant response to amplitude increases, gradual decay for decreases.
### 3. Normalization
$$\text{AGC}_i = \frac{\text{Signal}_i}{\text{Peak}_i}$$
Output is bounded to \[-1, +1\] for well-behaved oscillating inputs.
## Mathematical Foundation
### Transfer Characteristics
AGC is a nonlinear, time-varying gain element. The effective gain at bar $i$:
$$G_i = \frac{1}{\text{Peak}_i}$$
For a stationary sine wave with amplitude $A$ and period $P$, after sufficient bars the peak converges to:
$$\text{Peak}_\infty = A$$
since peak ratchets to $A$ at each cycle peak and the decay $\delta^{P/4}$ (quarter-cycle between peaks) is less than the ratchet-up. The normalized output then equals $\sin(\omega t)$ exactly.
### Decay Parameter Mapping
| Decay ($\delta$) | Half-life (bars) | Character |
|---|---|---|
| 0.95 | ~14 | Aggressive — fast adaptation |
| 0.98 | ~34 | Moderate |
| 0.991 | ~77 | Default — smooth adaptation |
| 0.999 | ~693 | Conservative — slow adaptation |
### Initialization
Peak initializes to $10^{-10}$ (tiny positive) to avoid division by zero on the first bar. After one bar with a finite input, peak ratchets to $|\text{input}|$ and normal operation begins.
## Performance Profile
| Metric | Value |
|---|---|
| Operations per bar | 1 multiply + 1 compare + 1 divide |
| Memory | 3 doubles (peak, lastValid, count) |
| Complexity | O(1) |
| Warmup | 1 bar |
| SIMD potential | Low (data-dependent branching) |
### Quality Metrics
| Metric | Score (1-10) |
|---|---|
| Amplitude normalization | 10 |
| Latency | 10 (zero delay) |
| Adaptation speed | 8 (asymmetric — fast up, slow down) |
| Noise sensitivity | 7 (peak tracks noise spikes) |
## Validation
AGC is a proprietary Ehlers normalizer with no external library implementations. Validation relies on self-consistency:
| Test | Status |
|---|---|
| Sine wave → bounded \[-1, +1\] | ✅ |
| Growing amplitude → stays bounded | ✅ |
| Decaying amplitude → peak adapts | ✅ |
| Streaming matches span | ✅ |
| Deterministic | ✅ |
| NaN-safe | ✅ |
| All 4 modes consistent | ✅ |
## Common Pitfalls
1. **Feeding raw price** — AGC on close prices produces a flatline near 1.0 because the peak tracks the price. Always pre-filter with a bandpass/roofing filter first.
2. **Decay too aggressive** — Low decay values (< 0.95) cause the peak to shrink rapidly between cycles, producing output that overshoots ±1 when the next peak arrives.
3. **Decay too conservative** — High decay values (> 0.999) make the normalizer sluggish; amplitude changes take hundreds of bars to reflect.
4. **Noise spikes** — A single large noise spike ratchets the peak up, compressing subsequent output until the peak decays back. Pre-filtering mitigates this.
5. **Conflating AGC with rescaling** — AGC is NOT min-max normalization. It tracks a running peak envelope, not the full range.
6. **Expecting symmetry** — AGC responds instantly to amplitude increases but requires $t_{1/2}$ bars to adapt to decreases. This asymmetry is intentional.
## Usage
```csharp
// Standalone AGC on pre-filtered signal
var roofing = new Roofing(48, 10);
var agc = new Agc(0.991);
foreach (var bar in series)
{
var filtered = roofing.Update(bar);
var normalized = agc.Update(filtered);
// normalized.Value is in [-1, +1]
}
// Span API
double[] prices = series.Values.ToArray();
double[] filtered = new double[prices.Length];
double[] output = new double[prices.Length];
Roofing.Batch(prices, filtered, 48, 10);
Agc.Batch(filtered, output, 0.991);
// Event chaining
var source = new TSeries();
var roofing = new Roofing(source, 48, 10);
var agc = new Agc(roofing, 0.991);
source.Add(new TValue(DateTime.UtcNow, close));
// agc.Last.Value is automatically updated
```
## References
- Ehlers, J. F. "The Universal Oscillator." *Technical Analysis of Stocks & Commodities*, January 2015.
- Ehlers, J. F. *Cycle Analytics for Traders*. Wiley, 2013.
-96
View File
@@ -1,96 +0,0 @@
// The MIT License (MIT)
// © mihakralj
//@version=6
// Indicator algorithm (C) 2015 John F. Ehlers
indicator("Ehlers Automatic Gain Control (AGC)", "AGC", overlay=false)
//@function Ehlers Automatic Gain Control — amplitude normalization via exponential peak tracking
//@param source Series to normalize (must oscillate around zero — use a filter output, not raw price)
//@param decay Peak decay factor per bar (controls adaptation speed; 0.991 ≈ 110-bar half-life)
//@returns Amplitude-normalized signal in [-1, +1] range
//@optimized O(1) per bar — single division + comparison, zero lookback
agc(series float src, simple float decay) =>
var float peak = 0.0000001
float ssrc = nz(src, 0.0)
// Exponential peak decay — shrinks peak toward zero between excitations
peak := decay * peak
// Track running peak — ratchets up when signal exceeds decayed peak
if math.abs(ssrc) > peak
peak := math.abs(ssrc)
// Guard against zero division (peak initialized to tiny positive value)
float result = peak > 0.0 ? ssrc / peak : 0.0
result
//@function Roofing filter — 2-pole HPF → Super Smoother bandpass for detrending raw price
//@param source Raw price series
//@param hpLength Highpass cutoff period (removes trend below this period)
//@param ssLength Super Smoother cutoff period (removes noise above this period)
//@returns Detrended, smoothed oscillation around zero
roofing(series float src, simple int hpLength, simple int ssLength) =>
var float SQRT2_PI = math.sqrt(2.0) * math.pi
// --- Stage 1: 2-pole Butterworth Highpass ---
int safe_hp = math.max(hpLength, 1)
var float hp_c1 = 0.0
var float hp_c2 = 0.0
var float hp_c3 = 0.0
var int prev_hp = 0
if prev_hp != safe_hp
float hp_arg = SQRT2_PI / float(safe_hp)
float hp_exp = math.exp(-hp_arg)
hp_c2 := 2.0 * hp_exp * math.cos(hp_arg)
hp_c3 := -hp_exp * hp_exp
hp_c1 := (1.0 + hp_c2 - hp_c3) / 4.0
prev_hp := safe_hp
var float hp = 0.0
float ssrc = nz(src, src[1])
float src1 = nz(src[1], ssrc)
float src2 = nz(src[2], src1)
hp := hp_c1 * (ssrc - 2.0 * src1 + src2) + hp_c2 * nz(hp[1], 0.0) + hp_c3 * nz(hp[2], 0.0)
// --- Stage 2: Super Smoother ---
int safe_ss = math.max(ssLength, 1)
var float ss_c1 = 0.0
var float ss_c2 = 0.0
var float ss_c3 = 0.0
var int prev_ss = 0
if prev_ss != safe_ss
float ss_arg = SQRT2_PI / float(safe_ss)
float ss_exp = math.exp(-ss_arg)
ss_c2 := 2.0 * ss_exp * math.cos(ss_arg)
ss_c3 := -ss_exp * ss_exp
ss_c1 := 1.0 - ss_c2 - ss_c3
prev_ss := safe_ss
var float roof = 0.0
roof := ss_c1 * hp + ss_c2 * nz(roof[1], hp) + ss_c3 * nz(roof[2], nz(hp[1], hp))
roof
// ---------- Main loop ----------
// Inputs
i_decay = input.float(0.991, "Decay", minval=0.9, maxval=0.9999, step=0.001,
tooltip="Peak decay factor per bar (0.991 ≈ 110-bar half-life)")
i_hpLength = input.int(48, "HP Length", minval=1,
tooltip="Highpass cutoff period — removes trend cycles longer than this")
i_ssLength = input.int(10, "SS Length", minval=1,
tooltip="Super Smoother cutoff — removes noise cycles shorter than this")
i_source = input.source(close, "Source")
// Preprocessing: Roofing filter detrends raw price into zero-mean oscillation
filt = roofing(i_source, i_hpLength, i_ssLength)
// AGC normalization of the detrended signal
agc_val = agc(filt, i_decay)
// Plot
plot(agc_val, "AGC", color=color.new(color.blue, 0), linewidth=2)
plot(filt, "Filter", color=color.new(color.gray, 60), linewidth=1)
hline(1.0, "+1", color=color.gray, linestyle=hline.style_dotted)
hline(0, "Zero", color=color.gray, linestyle=hline.style_dotted)
hline(-1.0, "-1", color=color.gray, linestyle=hline.style_dotted)
-103
View File
@@ -1,103 +0,0 @@
# REFLEX: Ehlers Reflex Indicator
> "John Ehlers measured how much a filtered price deviates from its own linear extrapolation. The result is a zero-lag oscillator that catches reversals before they happen, because the deviation is largest precisely when the trend is bending."
REFLEX is a zero-lag oscillator that measures the reversal tendency of price by comparing a Super-Smoother-filtered price against a linear extrapolation from $N$ bars ago. The filter computes the slope of the filtered series over the lookback window, projects a straight line, and sums the deviations of the actual filtered values from this projected line. The sum is normalized by an exponential RMS estimate to produce values in roughly $\pm \sigma$ scale. Values above 0 indicate uptrend, below 0 indicate downtrend; crossovers signal potential reversals.
## Historical Context
John F. Ehlers published REFLEX in "Reflex: A New Zero-Lag Indicator" (*Technical Analysis of Stocks & Commodities*, February 2020). Ehlers' motivation was to create a cycle-based oscillator that responds to trend reversals with zero lag, unlike traditional oscillators (RSI, stochastic) that inherently lag price due to their smoothing components.
The core idea is that linear extrapolation of a smoothed series will overshoot (undershoot) when the trend is decelerating (accelerating). By measuring the sum of these overshoots, REFLEX detects curvature changes — exactly the inflection points where trends reverse. This is mathematically similar to measuring the second derivative (acceleration), but the linear-extrapolation approach is more numerically stable and naturally adapts to the trend's own slope.
The 2-pole Super Smoother pre-filter (at half the specified period) removes high-frequency noise before the reflex computation, preventing false signals from bar-to-bar price noise. The exponential RMS normalization ensures the output has consistent scale regardless of the instrument's volatility.
## Architecture & Physics
### 1. Super Smoother Pre-Filter
A 2-pole IIR low-pass filter with cutoff at half the specified period:
$$
\text{Filt} = c_1 \cdot \frac{x_t + x_{t-1}}{2} + c_2 \cdot \text{Filt}_{t-1} + c_3 \cdot \text{Filt}_{t-2}
$$
where $a_1 = e^{-\sqrt{2}\pi / (N/2)}$, $c_2 = 2a_1\cos(\sqrt{2}\pi/(N/2))$, $c_3 = -a_1^2$, $c_1 = 1-c_2-c_3$.
### 2. Linear Extrapolation Slope
$$
\text{slope} = \frac{\text{Filt}_{t-N} - \text{Filt}_t}{N}
$$
### 3. Deviation Summation
$$
\text{Sum} = \frac{1}{N}\sum_{i=1}^{N}\left[(\text{Filt}_t + i \cdot \text{slope}) - \text{Filt}_{t-i}\right]
$$
### 4. Exponential RMS Normalization
$$
\text{MS} = 0.04 \cdot \text{Sum}^2 + 0.96 \cdot \text{MS}_{t-1}
$$
$$
\text{REFLEX} = \frac{\text{Sum}}{\sqrt{\text{MS}}}
$$
## Mathematical Foundation
**Super Smoother coefficients (half-period cutoff):**
$$
a_1 = e^{-\sqrt{2}\pi / (N/2)}, \quad c_2 = 2a_1\cos\!\left(\frac{\sqrt{2}\pi}{N/2}\right), \quad c_3 = -a_1^2, \quad c_1 = 1-c_2-c_3
$$
**Deviation from linear trend:**
$$
D_i = (\text{Filt}_t + i \cdot \text{slope}) - \text{Filt}_{t-i}, \quad i = 1, \ldots, N
$$
**Mean deviation:**
$$
\text{Sum} = \frac{1}{N}\sum_{i=1}^{N} D_i
$$
**Interpretation:**
- $\text{Sum} > 0$: filtered price is above its linear extrapolation (upward curvature, potential uptrend)
- $\text{Sum} < 0$: filtered price is below its linear extrapolation (downward curvature, potential downtrend)
- Zero crossings signal inflection points (trend reversals)
**Default parameters:** `period = 20`, `minPeriod = 2`. Output is an oscillator (not overlay).
**Pseudo-code (streaming):**
```
// Super Smoother (2-pole IIR)
filt = c1*(price + price[1])/2 + c2*filt[1] + c3*filt[2]
// Store in circular buffer
buf[head] = filt
// Slope from N-bar-ago to current
slope = (filt_lag_N - filt) / N
// Sum deviations from linear extrapolation
sum = 0
for i = 1 to N:
sum += (filt + i*slope) - filt[i]
sum /= N
// Normalize by exponential RMS
ms = 0.04 * sum² + 0.96 * ms[1]
return ms > 0 ? sum / sqrt(ms) : 0
```
## Resources
- Ehlers, J.F. (2020). "Reflex: A New Zero-Lag Indicator." *Technical Analysis of Stocks & Commodities*, February 2020.
- Ehlers, J.F. (2013). *Cycle Analytics for Traders*. Wiley. Chapter 3: Super Smoothers.
-90
View File
@@ -1,90 +0,0 @@
// This Pine Script™ code is subject to the terms of the Mozilla Public License 2.0
// https://mozilla.org/MPL/2.0/
// © QuanTAlib
//@version=6
indicator("Ehlers Reflex Indicator (REFLEX)", "REFLEX", overlay = false)
//@function Ehlers Reflex — a zero-lag oscillator that measures the reflex (reversal
// tendency) of price by comparing the SSF-filtered price against a linear
// extrapolation from N bars ago. Applies a 2-pole Super Smoother pre-filter
// at half the specified period, then computes slope = (Filt[N] - Filt) / N,
// sums deviations of the extrapolated line from actual filtered values over
// the window, and normalizes by exponential RMS. Values above 0 suggest
// uptrend, below 0 suggest downtrend; crossovers signal reversals.
//@param source Series to analyze
//@param period Lookback window / assumed cycle period (>= 2)
//@returns Reflex oscillator value (normalized, roughly ±σ scale)
//@reference Ehlers, J.F. (2020). "Reflex: A New Zero-Lag Indicator."
// Technical Analysis of Stocks & Commodities, Feb 2020.
//@optimized O(period) per bar for the summation loop; SSF is O(1) IIR
export reflex(series float source, simple int period) =>
if period < 2
runtime.error("Period must be at least 2")
float price = nz(source)
// --- 2-Pole Super Smoother Filter (half-period cutoff) ---
float half_period = period * 0.5
float a1 = math.exp(-1.414 * math.pi / half_period)
float b1 = 2.0 * a1 * math.cos(1.414 * math.pi / half_period)
float c2 = b1
float c3 = -(a1 * a1)
float c1 = 1.0 - c2 - c3
var float filt = 0.0
var float filt1 = 0.0
var float filt2 = 0.0
float src1 = nz(source[1])
filt2 := filt1
filt1 := filt
filt := c1 * (price + src1) * 0.5 + c2 * filt1 + c3 * filt2
// --- Circular buffer to store filtered values for lookback ---
var array<float> buf = array.new_float(period + 1, 0.0)
var int head = 0
array.set(buf, head, filt)
int count = math.min(bar_index + 1, period)
// --- Slope: (Filt[Length] - Filt) / Length ---
int lag_idx = (head - period + period + 1) % (period + 1)
float filt_lag = array.get(buf, lag_idx)
float slope = (filt_lag - filt) / period
// --- Sum the differences ---
// Sum = Σ(i=1..Length) [(Filt + i*Slope) - Filt[i]] / Length
float the_sum = 0.0
if count >= period
for i = 1 to period
int idx = (head - i + period + 1) % (period + 1)
float filt_i = array.get(buf, idx)
the_sum += (filt + float(i) * slope) - filt_i
the_sum /= period
// --- Advance head ---
head := (head + 1) % (period + 1)
// --- Normalize in terms of Standard Deviations ---
// MS = 0.04 * Sum² + 0.96 * MS[1] (exponential RMS)
var float ms = 0.0
ms := 0.04 * the_sum * the_sum + 0.96 * ms
float result = 0.0
if ms > 0.0
result := the_sum / math.sqrt(ms)
result
// ── Inputs ──
int p_period = input.int(20, "Period", minval = 2)
float p_src = input.source(close, "Source")
// ── Calculation ──
float out = reflex(p_src, p_period)
// ── Plot ──
plot(out, "REFLEX", color.yellow, 2)
hline(0, "Zero", color.gray)
hline(1.0, "+1σ", color.new(color.red, 60))
hline(-1.0, "-1σ", color.new(color.green, 60))