mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-17 10:08: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:
@@ -7,6 +7,7 @@ Basic mathematical transforms and utility functions for time series. These build
|
||||
| Indicator | Full Name | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| [ACCEL](accel/Accel.md) | Acceleration | Momentum change; second derivative of price. |
|
||||
| [AGC](agc/Agc.md) | Ehlers Automatic Gain Control | Amplitude normalization via exponential peak tracking. |
|
||||
| [CHANGE](change/Change.md) | Percentage Change | Relative price movement over lookback period. |
|
||||
| [EXPTRANS](exptrans/Exptrans.md) | Exponential Transform | e^x transform for log-space conversion reversal. |
|
||||
| [HIGHEST](highest/Highest.md) | Rolling Maximum | Maximum value over lookback window. |
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
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/numerics/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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,96 @@
|
||||
// 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)
|
||||
Reference in New Issue
Block a user