Add TRAMA implementation and comprehensive tests

- Implemented the TRAMA (Trend Regularity Adaptive Moving Average) class with adaptive EMA logic.
- Added unit tests for TRAMA functionality, including constructor validation, basic calculations, state management, and robustness checks.
- Created validation tests to ensure consistency across different modes of operation (streaming, batch, and static calculations).
- Enhanced documentation for TRAMA, including performance profiles and quality metrics.
- Updated workspace configuration by removing unnecessary folder references.
This commit is contained in:
Miha Kralj
2026-02-21 20:45:38 -08:00
parent 90d5638008
commit 7253f61299
199 changed files with 29577 additions and 234 deletions
+1
View File
@@ -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. |
+127
View File
@@ -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);
}
}
+61
View File
@@ -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);
}
}
+430
View File
@@ -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);
}
}
+196
View File
@@ -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");
}
}
+236
View File
@@ -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);
}
}
+146
View File
@@ -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.
+96
View File
@@ -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)