mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-21 20:18:05 +00:00
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:
@@ -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. |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user