mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-23 04:58:08 +00:00
filters update
This commit is contained in:
+15
-4
@@ -7,18 +7,29 @@ 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. |
|
||||
| BETADIST | Beta Distribution | Beta probability distribution transform. |
|
||||
| BINOMDIST | Binomial Distribution | Binomial probability distribution transform. |
|
||||
| [CHANGE](change/Change.md) | Percentage Change | Relative price movement over lookback period. |
|
||||
| CWT | Continuous Wavelet Transform | Time-frequency decomposition with continuous wavelets. |
|
||||
| DWT | Discrete Wavelet Transform | Multi-resolution signal decomposition. |
|
||||
| EXPDIST | Exponential Distribution | Exponential probability distribution transform. |
|
||||
| [EXPTRANS](exptrans/Exptrans.md) | Exponential Transform | e^x transform for log-space conversion reversal. |
|
||||
| FDIST | F-Distribution | Fisher-Snedecor probability distribution transform. |
|
||||
| FFT | Fast Fourier Transform | Frequency-domain decomposition via FFT algorithm. |
|
||||
| GAMMADIST | Gamma Distribution | Gamma probability distribution transform. |
|
||||
| [HIGHEST](highest/Highest.md) | Rolling Maximum | Maximum value over lookback window. |
|
||||
| IFFT | Inverse Fast Fourier Transform | Frequency-to-time domain reconstruction. |
|
||||
| [JERK](jerk/Jerk.md) | Jerk | Rate of acceleration; third derivative of price. |
|
||||
| [LINEARTRANS](lineartrans/Lineartrans.md) | Linear Transform | y = ax + b scaling transformation. |
|
||||
| LOGNORMDIST | Log-normal Distribution | Log-normal probability distribution transform. |
|
||||
| [LOGTRANS](logtrans/Logtrans.md) | Logarithmic Transform | Natural log for percentage-based analysis. |
|
||||
| [LOWEST](lowest/Lowest.md) | Rolling Minimum | Minimum value over lookback window. |
|
||||
| [MIDPOINT](midpoint/Midpoint.md) | Midrange | (Highest + Lowest) / 2 over lookback window. |
|
||||
| NORMDIST | Normal Distribution | Gaussian probability distribution transform. |
|
||||
| [NORMALIZE](normalize/Normalize.md) | Min-Max Normalization | Scale to [0,1] range using rolling min/max. |
|
||||
| POISSONDIST | Poisson Distribution | Poisson probability distribution transform. |
|
||||
| [RELU](relu/Relu.md) | Rectified Linear Unit | max(0, x); neural network activation function. |
|
||||
| [SIGMOID](sigmoid/Sigmoid.md) | Logistic Function | 1/(1+e^-x); bounded [0,1] transform. |
|
||||
| [SLOPE](slope/Slope.md) | Rate of Change | First derivative; velocity of price movement. |
|
||||
| [SLOPE](slope/Slope.md) | First Derivative | First derivative; velocity of price movement. |
|
||||
| [SQRTTRANS](sqrttrans/Sqrttrans.md) | Square Root Transform | Variance-stabilizing transformation. |
|
||||
| [STANDARDIZE](standardize/Standardize.md) | Z-Score Normalization | (x - mean) / stddev; zero-mean unit-variance transform. |
|
||||
| TDIST | Student's t-Distribution | Student's t probability distribution transform. |
|
||||
| WEIBULLDIST | Weibull Distribution | Weibull probability distribution transform. |
|
||||
|
||||
@@ -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/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);
|
||||
}
|
||||
}
|
||||
@@ -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,245 +0,0 @@
|
||||
using Xunit;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class MidpointIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void MidpointIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new MidpointIndicator();
|
||||
|
||||
Assert.Equal(14, indicator.Period);
|
||||
Assert.Equal(SourceType.Close, indicator.Source);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("MIDPOINT - Rolling Range Midpoint", indicator.Name);
|
||||
Assert.False(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MidpointIndicator_MinHistoryDepths_EqualsPeriod()
|
||||
{
|
||||
var indicator = new MidpointIndicator { Period = 20 };
|
||||
Assert.Equal(20, indicator.MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MidpointIndicator_ShortName_IncludesPeriod()
|
||||
{
|
||||
var indicator = new MidpointIndicator { Period = 14 };
|
||||
Assert.Equal("MIDPOINT(14)", indicator.ShortName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MidpointIndicator_Initialize_CreatesLineSeries()
|
||||
{
|
||||
var indicator = new MidpointIndicator();
|
||||
indicator.Initialize();
|
||||
|
||||
Assert.Single(indicator.LinesSeries);
|
||||
Assert.Equal("Midpoint", indicator.LinesSeries[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MidpointIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new MidpointIndicator { Period = 5 };
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MidpointIndicator_ProcessUpdate_NewBar_ComputesValue()
|
||||
{
|
||||
var indicator = new MidpointIndicator { Period = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 92, 106);
|
||||
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
Assert.Equal(2, indicator.LinesSeries[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MidpointIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
|
||||
{
|
||||
var indicator = new MidpointIndicator { Period = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
|
||||
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
|
||||
|
||||
Assert.Equal(2, indicator.LinesSeries[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MidpointIndicator_MultipleUpdates_ProducesCorrectSequence()
|
||||
{
|
||||
var indicator = new MidpointIndicator { Period = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(
|
||||
now.AddMinutes(i),
|
||||
100 + i * 2,
|
||||
105 + i * 2,
|
||||
95 + i * 2,
|
||||
102 + i * 2);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
Assert.Equal(20, indicator.LinesSeries[0].Count);
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(i)));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MidpointIndicator_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 MidpointIndicator { Period = 5, Source = source };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
indicator.HistoricalData.AddBar(now, 100, 110, 90, 105);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
Assert.Equal(1, indicator.LinesSeries[0].Count);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MidpointIndicator_ShowColdValues_False_SetsNaN()
|
||||
{
|
||||
var indicator = new MidpointIndicator { Period = 10, ShowColdValues = false };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
Assert.True(double.IsNaN(indicator.LinesSeries[0].GetValue(0)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MidpointIndicator_ComputesMidpoint_Correctly()
|
||||
{
|
||||
var indicator = new MidpointIndicator { Period = 5, Source = SourceType.Close };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Close prices: 100, 110, 90, 105, 95
|
||||
// Highest = 110, Lowest = 90, Midpoint = (110 + 90) / 2 = 100
|
||||
double[] closes = { 100, 110, 90, 105, 95 };
|
||||
for (int i = 0; i < closes.Length; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), closes[i], closes[i] + 5, closes[i] - 5, closes[i]);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double lastMidpoint = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.Equal(100, lastMidpoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MidpointIndicator_WindowSlides_Correctly()
|
||||
{
|
||||
var indicator = new MidpointIndicator { Period = 3, Source = SourceType.Close };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Closes: 100, 120, 80, 90, 110
|
||||
// After 5 bars, window = [80, 90, 110]
|
||||
// Highest = 110, Lowest = 80, Midpoint = 95
|
||||
double[] closes = { 100, 120, 80, 90, 110 };
|
||||
for (int i = 0; i < closes.Length; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), closes[i], closes[i] + 5, closes[i] - 5, closes[i]);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double lastMidpoint = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.Equal(95, lastMidpoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MidpointIndicator_SymmetricRange_MidpointEqualsCenter()
|
||||
{
|
||||
var indicator = new MidpointIndicator { Period = 3, Source = SourceType.Close };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Symmetric: 50, 100, 150 -> midpoint = (150 + 50) / 2 = 100
|
||||
double[] closes = { 50, 100, 150 };
|
||||
for (int i = 0; i < closes.Length; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), closes[i], closes[i] + 5, closes[i] - 5, closes[i]);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double midpoint = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.Equal(100, midpoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MidpointIndicator_DifferentPeriods_Work()
|
||||
{
|
||||
var periods = new[] { 5, 10, 20, 50 };
|
||||
|
||||
foreach (int period in periods)
|
||||
{
|
||||
var indicator = new MidpointIndicator { Period = period };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < period + 10; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(
|
||||
now.AddMinutes(i),
|
||||
100 + i,
|
||||
105 + i,
|
||||
95 + i,
|
||||
102 + i);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
Assert.Equal(period + 10, indicator.LinesSeries[0].Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
using System.Drawing;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
using static QuanTAlib.IndicatorExtensions;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// MIDPOINT (Rolling Range Midpoint) Quantower indicator.
|
||||
/// Calculates (Highest + Lowest) / 2 over a rolling lookback window.
|
||||
/// </summary>
|
||||
public class MidpointIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("Period", sortIndex: 0, minimum: 1, maximum: 1000)]
|
||||
public int Period { get; set; } = 14;
|
||||
|
||||
[DataSourceInput]
|
||||
public SourceType Source { get; set; } = SourceType.Close;
|
||||
|
||||
[InputParameter("Show Cold Values", sortIndex: 100)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Midpoint? _midpoint;
|
||||
private Func<IHistoryItem, double>? _selector;
|
||||
|
||||
public int MinHistoryDepths => Period;
|
||||
public override string ShortName => $"MIDPOINT({Period})";
|
||||
|
||||
public MidpointIndicator()
|
||||
{
|
||||
Name = "MIDPOINT - Rolling Range Midpoint";
|
||||
Description = "Calculates (Highest + Lowest) / 2 over a rolling lookback window";
|
||||
SeparateWindow = false;
|
||||
OnBackGround = true;
|
||||
}
|
||||
|
||||
protected override void OnInit()
|
||||
{
|
||||
_midpoint = new Midpoint(Period);
|
||||
_selector = Source.GetPriceSelector();
|
||||
|
||||
AddLineSeries(new LineSeries("Midpoint", Color.Blue, 2, LineStyle.Solid));
|
||||
}
|
||||
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
if (_midpoint == null || _selector == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var item = HistoricalData[0, SeekOriginHistory.End];
|
||||
double value = _selector(item);
|
||||
bool isNew = args.IsNewBar();
|
||||
|
||||
TValue input = new(item.TimeLeft, value);
|
||||
_midpoint.Update(input, isNew);
|
||||
|
||||
bool isHot = _midpoint.IsHot;
|
||||
|
||||
LinesSeries[0].SetValue(_midpoint.Last.Value, isHot, ShowColdValues);
|
||||
}
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class MidpointTests
|
||||
{
|
||||
private const double Tolerance = 1e-10;
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidPeriod_ThrowsArgumentException()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new Midpoint(0));
|
||||
Assert.Throws<ArgumentException>(() => new Midpoint(-1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ValidPeriod_SetsProperties()
|
||||
{
|
||||
var indicator = new Midpoint(14);
|
||||
Assert.Equal("Midpoint(14)", indicator.Name);
|
||||
Assert.Equal(14, indicator.WarmupPeriod);
|
||||
Assert.False(indicator.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_ReturnsMidpointInWindow()
|
||||
{
|
||||
var indicator = new Midpoint(3);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
// Single value: midpoint = (5+5)/2 = 5
|
||||
indicator.Update(new TValue(time, 5.0));
|
||||
Assert.Equal(5.0, indicator.Last.Value, Tolerance);
|
||||
|
||||
// Window [5, 8]: midpoint = (8+5)/2 = 6.5
|
||||
indicator.Update(new TValue(time.AddMinutes(1), 8.0));
|
||||
Assert.Equal(6.5, indicator.Last.Value, Tolerance);
|
||||
|
||||
// Window [5, 8, 3]: midpoint = (8+3)/2 = 5.5
|
||||
indicator.Update(new TValue(time.AddMinutes(2), 3.0));
|
||||
Assert.Equal(5.5, indicator.Last.Value, Tolerance);
|
||||
|
||||
// Window [8, 3, 2]: midpoint = (8+2)/2 = 5.0
|
||||
indicator.Update(new TValue(time.AddMinutes(3), 2.0));
|
||||
Assert.Equal(5.0, indicator.Last.Value, Tolerance);
|
||||
|
||||
// Window [3, 2, 10]: midpoint = (10+2)/2 = 6.0
|
||||
indicator.Update(new TValue(time.AddMinutes(4), 10.0));
|
||||
Assert.Equal(6.0, indicator.Last.Value, Tolerance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_Period1_ReturnsSameValue()
|
||||
{
|
||||
var indicator = new Midpoint(1);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
double value = i * 2.5;
|
||||
indicator.Update(new TValue(time.AddMinutes(i), value));
|
||||
// Midpoint of single value = that value
|
||||
Assert.Equal(value, indicator.Last.Value, Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_IsNewFalse_CorrectsPreviousValue()
|
||||
{
|
||||
var indicator = new Midpoint(5);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
indicator.Update(new TValue(time, 10.0));
|
||||
indicator.Update(new TValue(time.AddMinutes(1), 20.0));
|
||||
indicator.Update(new TValue(time.AddMinutes(2), 15.0));
|
||||
// Window [10, 20, 15]: midpoint = (20+10)/2 = 15.0
|
||||
Assert.Equal(15.0, indicator.Last.Value, Tolerance);
|
||||
|
||||
// Correct last value to 5.0
|
||||
// Window [10, 20, 5]: midpoint = (20+5)/2 = 12.5
|
||||
indicator.Update(new TValue(time.AddMinutes(2), 5.0), isNew: false);
|
||||
Assert.Equal(12.5, indicator.Last.Value, Tolerance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_IterativeCorrection_RestoresState()
|
||||
{
|
||||
var indicator = new Midpoint(5);
|
||||
var time = DateTime.UtcNow;
|
||||
double[] values = { 5.0, 10.0, 8.0, 12.0, 7.0, 15.0, 11.0 };
|
||||
|
||||
// Process all values
|
||||
foreach (var v in values)
|
||||
{
|
||||
indicator.Update(new TValue(time, v));
|
||||
time = time.AddMinutes(1);
|
||||
}
|
||||
double finalResult = indicator.Last.Value;
|
||||
|
||||
// Reset and process with corrections
|
||||
indicator.Reset();
|
||||
time = DateTime.UtcNow;
|
||||
foreach (var v in values)
|
||||
{
|
||||
// Submit wrong value first
|
||||
indicator.Update(new TValue(time, 0.0));
|
||||
// Correct it
|
||||
indicator.Update(new TValue(time, v), isNew: false);
|
||||
time = time.AddMinutes(1);
|
||||
}
|
||||
|
||||
Assert.Equal(finalResult, indicator.Last.Value, Tolerance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_NaN_UsesLastValidValue()
|
||||
{
|
||||
var indicator = new Midpoint(5);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
indicator.Update(new TValue(time, 10.0));
|
||||
indicator.Update(new TValue(time.AddMinutes(1), 20.0));
|
||||
double beforeNaN = indicator.Last.Value;
|
||||
|
||||
indicator.Update(new TValue(time.AddMinutes(2), double.NaN));
|
||||
// Should use last valid value
|
||||
Assert.Equal(beforeNaN, indicator.Last.Value, Tolerance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_Infinity_UsesLastValidValue()
|
||||
{
|
||||
var indicator = new Midpoint(5);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
indicator.Update(new TValue(time, 15.0));
|
||||
indicator.Update(new TValue(time.AddMinutes(1), double.PositiveInfinity));
|
||||
Assert.True(double.IsFinite(indicator.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_BecomesTrueAfterWarmup()
|
||||
{
|
||||
var indicator = new Midpoint(5);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
indicator.Update(new TValue(time.AddMinutes(i), i));
|
||||
Assert.False(indicator.IsHot);
|
||||
}
|
||||
|
||||
indicator.Update(new TValue(time.AddMinutes(4), 4));
|
||||
Assert.True(indicator.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
var indicator = new Midpoint(5);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
indicator.Update(new TValue(time.AddMinutes(i), i * 2));
|
||||
}
|
||||
|
||||
Assert.True(indicator.IsHot);
|
||||
indicator.Reset();
|
||||
Assert.False(indicator.IsHot);
|
||||
Assert.Equal(default, indicator.Last);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pub_EventFires()
|
||||
{
|
||||
var indicator = new Midpoint(5);
|
||||
int eventCount = 0;
|
||||
indicator.Pub += (object? sender, in TValueEventArgs args) => eventCount++;
|
||||
|
||||
indicator.Update(new TValue(DateTime.UtcNow, 10.0));
|
||||
Assert.Equal(1, eventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chaining_Constructor_Works()
|
||||
{
|
||||
var source = new TSeries();
|
||||
var indicator = new Midpoint(source, 5);
|
||||
|
||||
source.Add(new TValue(DateTime.UtcNow, 10.0), true);
|
||||
Assert.Equal(10.0, indicator.Last.Value, Tolerance);
|
||||
|
||||
// Window [10, 20]: midpoint = (20+10)/2 = 15
|
||||
source.Add(new TValue(DateTime.UtcNow.AddMinutes(1), 20.0), true);
|
||||
Assert.Equal(15.0, indicator.Last.Value, Tolerance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_TSeries_MatchesStreaming()
|
||||
{
|
||||
int period = 5;
|
||||
int count = 50;
|
||||
var gbm = new GBM(10000);
|
||||
var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var source = bars.Close;
|
||||
|
||||
// Streaming
|
||||
var streaming = new Midpoint(period);
|
||||
var streamingResults = new List<double>();
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
streaming.Update(source[i]);
|
||||
streamingResults.Add(streaming.Last.Value);
|
||||
}
|
||||
|
||||
// Batch
|
||||
var batch = Midpoint.Batch(source, period);
|
||||
|
||||
// Compare last values (after warmup)
|
||||
for (int i = period; i < source.Count; i++)
|
||||
{
|
||||
Assert.Equal(streamingResults[i], batch[i].Value, Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_Span_MatchesTSeries()
|
||||
{
|
||||
int period = 5;
|
||||
int count = 50;
|
||||
var gbm = new GBM(10001);
|
||||
var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var source = bars.Close;
|
||||
|
||||
// TSeries batch
|
||||
var batchResult = Midpoint.Batch(source, period);
|
||||
|
||||
// Span calculation
|
||||
var sourceArray = source.Values.ToArray();
|
||||
var output = new double[count];
|
||||
Midpoint.Batch(sourceArray.AsSpan(), output.AsSpan(), period);
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Assert.Equal(batchResult[i].Value, output[i], Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_Span_ValidatesArguments()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
{
|
||||
Span<double> output = stackalloc double[10];
|
||||
Midpoint.Batch(ReadOnlySpan<double>.Empty, output, 5);
|
||||
});
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
{
|
||||
ReadOnlySpan<double> source = stackalloc double[10];
|
||||
Span<double> output = stackalloc double[5];
|
||||
Midpoint.Batch(source, output, 5);
|
||||
});
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
{
|
||||
ReadOnlySpan<double> source = stackalloc double[10];
|
||||
Span<double> output = stackalloc double[10];
|
||||
Midpoint.Batch(source, output, 0);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstantSequence_ReturnsSameValue()
|
||||
{
|
||||
var indicator = new Midpoint(5);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
indicator.Update(new TValue(time.AddMinutes(i), 7.5));
|
||||
// Midpoint of constant sequence = that constant
|
||||
Assert.Equal(7.5, indicator.Last.Value, Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Midpoint_EqualsAverageOfHighestAndLowest()
|
||||
{
|
||||
int period = 5;
|
||||
var gbm = new GBM(12345);
|
||||
var bars = gbm.Fetch(30, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var source = bars.Close;
|
||||
|
||||
var midpoint = new Midpoint(period);
|
||||
var highest = new Highest(period);
|
||||
var lowest = new Lowest(period);
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
midpoint.Update(source[i]);
|
||||
highest.Update(source[i]);
|
||||
lowest.Update(source[i]);
|
||||
|
||||
double expected = (highest.Last.Value + lowest.Last.Value) * 0.5;
|
||||
Assert.Equal(expected, midpoint.Last.Value, Tolerance);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
using TALib;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class MidpointValidationTests : IDisposable
|
||||
{
|
||||
private readonly ValidationTestData _testData;
|
||||
private readonly ITestOutputHelper _output;
|
||||
private bool _disposed;
|
||||
|
||||
public MidpointValidationTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
_testData = new ValidationTestData();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
}
|
||||
|
||||
private void Dispose(bool disposing)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
if (disposing)
|
||||
{
|
||||
_testData?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Talib_Batch()
|
||||
{
|
||||
int[] periods = { 5, 10, 14, 20, 50 };
|
||||
|
||||
double[] tData = _testData.RawData.ToArray();
|
||||
double[] output = new double[tData.Length];
|
||||
|
||||
foreach (var period in periods)
|
||||
{
|
||||
// Calculate QuanTAlib Midpoint (batch TSeries)
|
||||
var midpoint = new Midpoint(period);
|
||||
var qResult = midpoint.Update(_testData.Data);
|
||||
|
||||
// Calculate TA-Lib MIDPOINT
|
||||
var retCode = TALib.Functions.MidPoint<double>(tData, 0..^0, output, out var outRange, period);
|
||||
Assert.Equal(Core.RetCode.Success, retCode);
|
||||
|
||||
int lookback = TALib.Functions.MidPointLookback(period);
|
||||
|
||||
// Compare last 100 records
|
||||
ValidationHelper.VerifyData(qResult, output, outRange, lookback);
|
||||
}
|
||||
_output.WriteLine("Midpoint Batch(TSeries) validated successfully against TA-Lib MIDPOINT");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Talib_Streaming()
|
||||
{
|
||||
int[] periods = { 5, 10, 14, 20, 50 };
|
||||
|
||||
double[] tData = _testData.RawData.ToArray();
|
||||
double[] output = new double[tData.Length];
|
||||
|
||||
foreach (var period in periods)
|
||||
{
|
||||
// Calculate QuanTAlib Midpoint (streaming)
|
||||
var midpoint = new Midpoint(period);
|
||||
var qResults = new List<double>();
|
||||
foreach (var item in _testData.Data)
|
||||
{
|
||||
qResults.Add(midpoint.Update(item).Value);
|
||||
}
|
||||
|
||||
// Calculate TA-Lib MIDPOINT
|
||||
var retCode = TALib.Functions.MidPoint<double>(tData, 0..^0, output, out var outRange, period);
|
||||
Assert.Equal(Core.RetCode.Success, retCode);
|
||||
|
||||
int lookback = TALib.Functions.MidPointLookback(period);
|
||||
|
||||
// Compare last 100 records
|
||||
ValidationHelper.VerifyData(qResults, output, outRange, lookback);
|
||||
}
|
||||
_output.WriteLine("Midpoint Streaming validated successfully against TA-Lib MIDPOINT");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Talib_Span()
|
||||
{
|
||||
int[] periods = { 5, 10, 14, 20, 50 };
|
||||
|
||||
double[] sourceData = _testData.RawData.ToArray();
|
||||
double[] talibOutput = new double[sourceData.Length];
|
||||
|
||||
foreach (var period in periods)
|
||||
{
|
||||
// Calculate QuanTAlib Midpoint (Span API)
|
||||
double[] qOutput = new double[sourceData.Length];
|
||||
Midpoint.Batch(sourceData.AsSpan(), qOutput.AsSpan(), period);
|
||||
|
||||
// Calculate TA-Lib MIDPOINT
|
||||
var retCode = TALib.Functions.MidPoint<double>(sourceData, 0..^0, talibOutput, out var outRange, period);
|
||||
Assert.Equal(Core.RetCode.Success, retCode);
|
||||
|
||||
int lookback = TALib.Functions.MidPointLookback(period);
|
||||
|
||||
// Compare last 100 records
|
||||
ValidationHelper.VerifyData(qOutput, talibOutput, outRange, lookback);
|
||||
}
|
||||
_output.WriteLine("Midpoint Span validated successfully against TA-Lib MIDPOINT");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_KnownValues()
|
||||
{
|
||||
// Test with simple known sequence
|
||||
double[] data = { 1, 5, 3, 8, 2, 9, 4, 7, 6, 10 };
|
||||
int period = 3;
|
||||
|
||||
// For each window:
|
||||
// [1] -> (1+1)/2 = 1
|
||||
// [1,5] -> (5+1)/2 = 3
|
||||
// [1,5,3] -> (5+1)/2 = 3
|
||||
// [5,3,8] -> (8+3)/2 = 5.5
|
||||
// [3,8,2] -> (8+2)/2 = 5
|
||||
// [8,2,9] -> (9+2)/2 = 5.5
|
||||
// [2,9,4] -> (9+2)/2 = 5.5
|
||||
// [9,4,7] -> (9+4)/2 = 6.5
|
||||
// [4,7,6] -> (7+4)/2 = 5.5
|
||||
// [7,6,10] -> (10+6)/2 = 8
|
||||
double[] expected = { 1, 3, 3, 5.5, 5, 5.5, 5.5, 6.5, 5.5, 8 };
|
||||
|
||||
var midpoint = new Midpoint(period);
|
||||
for (int i = 0; i < data.Length; i++)
|
||||
{
|
||||
var result = midpoint.Update(new TValue(DateTime.UtcNow, data[i]));
|
||||
Assert.Equal(expected[i], result.Value, precision: 10);
|
||||
}
|
||||
_output.WriteLine("Midpoint validated with known values");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ConsistencyWithHighestLowest()
|
||||
{
|
||||
// Verify that Midpoint = (Highest + Lowest) / 2
|
||||
int period = 14;
|
||||
var midpoint = new Midpoint(period);
|
||||
var highest = new Highest(period);
|
||||
var lowest = new Lowest(period);
|
||||
|
||||
foreach (var item in _testData.Data)
|
||||
{
|
||||
var midResult = midpoint.Update(item);
|
||||
var highResult = highest.Update(item);
|
||||
var lowResult = lowest.Update(item);
|
||||
|
||||
double expected = (highResult.Value + lowResult.Value) * 0.5;
|
||||
Assert.Equal(expected, midResult.Value, precision: 10);
|
||||
}
|
||||
_output.WriteLine("Midpoint consistency validated: equals (Highest + Lowest) / 2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ConstantInput()
|
||||
{
|
||||
// For constant input, midpoint should equal that constant
|
||||
double constant = 42.5;
|
||||
int period = 10;
|
||||
var midpoint = new Midpoint(period);
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
var result = midpoint.Update(new TValue(DateTime.UtcNow, constant));
|
||||
Assert.Equal(constant, result.Value, precision: 10);
|
||||
}
|
||||
_output.WriteLine("Midpoint validated with constant input");
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
// MIDPOINT: Rolling Midpoint - (Highest + Lowest) / 2 over lookback window
|
||||
// Composes Highest and Lowest indicators for efficient calculation
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// MIDPOINT: Rolling Midpoint
|
||||
/// Calculates the midpoint ((highest + lowest) / 2) over a specified lookback period.
|
||||
/// Composes Highest and Lowest indicators internally.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Key properties:
|
||||
/// - Returns the center of the price range within the lookback window
|
||||
/// - Useful for mean reversion, channel center, trend direction
|
||||
/// - Can be validated against TA-Lib MIDPOINT function
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Midpoint : AbstractBase
|
||||
{
|
||||
private readonly Highest _highest;
|
||||
private readonly Lowest _lowest;
|
||||
private readonly ITValuePublisher? _source;
|
||||
private readonly TValuePublishedHandler? _handler;
|
||||
private bool _disposed;
|
||||
|
||||
public override bool IsHot => _highest.IsHot && _lowest.IsHot;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new Midpoint indicator with specified lookback period.
|
||||
/// </summary>
|
||||
/// <param name="period">Lookback window size (must be >= 1)</param>
|
||||
public Midpoint(int period)
|
||||
{
|
||||
if (period < 1)
|
||||
{
|
||||
throw new ArgumentException("Period must be >= 1", nameof(period));
|
||||
}
|
||||
|
||||
_highest = new Highest(period);
|
||||
_lowest = new Lowest(period);
|
||||
Name = $"Midpoint({period})";
|
||||
WarmupPeriod = period;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new Midpoint indicator with source for event-based chaining.
|
||||
/// </summary>
|
||||
/// <param name="source">Source indicator for chaining</param>
|
||||
/// <param name="period">Lookback window size</param>
|
||||
public Midpoint(ITValuePublisher source, int period) : this(period)
|
||||
{
|
||||
_source = source;
|
||||
_handler = HandleUpdate;
|
||||
_source.Pub += _handler;
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
if (disposing && _source != null && _handler != null)
|
||||
{
|
||||
_source.Pub -= _handler;
|
||||
}
|
||||
_disposed = true;
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void HandleUpdate(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public override TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
TValue high = _highest.Update(input, isNew);
|
||||
TValue low = _lowest.Update(input, isNew);
|
||||
|
||||
double result = (high.Value + low.Value) * 0.5;
|
||||
|
||||
Last = new TValue(input.Time, result);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
public override TSeries Update(TSeries source)
|
||||
{
|
||||
var result = new TSeries(source.Count);
|
||||
ReadOnlySpan<double> values = source.Values;
|
||||
ReadOnlySpan<long> times = source.Times;
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
var tv = Update(new TValue(new DateTime(times[i], DateTimeKind.Utc), values[i]), true);
|
||||
result.Add(tv, true);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
|
||||
{
|
||||
TimeSpan interval = step ?? TimeSpan.FromSeconds(1);
|
||||
DateTime time = DateTime.UtcNow - (interval * source.Length);
|
||||
|
||||
for (int i = 0; i < source.Length; i++)
|
||||
{
|
||||
Update(new TValue(time, source[i]), true);
|
||||
time += interval;
|
||||
}
|
||||
}
|
||||
|
||||
public static TSeries Batch(TSeries source, int period)
|
||||
{
|
||||
var indicator = new Midpoint(period);
|
||||
return indicator.Update(source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates rolling midpoint over a span of values.
|
||||
/// </summary>
|
||||
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period)
|
||||
{
|
||||
if (source.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("Source cannot be empty", nameof(source));
|
||||
}
|
||||
|
||||
if (output.Length < source.Length)
|
||||
{
|
||||
throw new ArgumentException("Output length must be >= source length", nameof(output));
|
||||
}
|
||||
|
||||
if (period < 1)
|
||||
{
|
||||
throw new ArgumentException("Period must be >= 1", nameof(period));
|
||||
}
|
||||
|
||||
int len = source.Length;
|
||||
|
||||
// Use ArrayPool for large arrays to avoid stack overflow
|
||||
double[]? rentedHigh = null;
|
||||
double[]? rentedLow = null;
|
||||
|
||||
#pragma warning disable S1121 // Assignments should not be made from within sub-expressions
|
||||
Span<double> highBuffer = len <= 256
|
||||
? stackalloc double[len]
|
||||
: (rentedHigh = System.Buffers.ArrayPool<double>.Shared.Rent(len)).AsSpan(0, len);
|
||||
|
||||
Span<double> lowBuffer = len <= 256
|
||||
? stackalloc double[len]
|
||||
: (rentedLow = System.Buffers.ArrayPool<double>.Shared.Rent(len)).AsSpan(0, len);
|
||||
#pragma warning restore S1121
|
||||
|
||||
try
|
||||
{
|
||||
Highest.Batch(source, highBuffer, period);
|
||||
Lowest.Batch(source, lowBuffer, period);
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
output[i] = (highBuffer[i] + lowBuffer[i]) * 0.5;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (rentedHigh != null)
|
||||
{
|
||||
System.Buffers.ArrayPool<double>.Shared.Return(rentedHigh);
|
||||
}
|
||||
|
||||
if (rentedLow != null)
|
||||
{
|
||||
System.Buffers.ArrayPool<double>.Shared.Return(rentedLow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static (TSeries Results, Midpoint Indicator) Calculate(TSeries source, int period)
|
||||
{
|
||||
var indicator = new Midpoint(period);
|
||||
TSeries results = indicator.Update(source);
|
||||
return (results, indicator);
|
||||
}
|
||||
|
||||
public override void Reset()
|
||||
{
|
||||
_highest.Reset();
|
||||
_lowest.Reset();
|
||||
Last = default;
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
# MIDPOINT: Rolling Range Midpoint
|
||||
|
||||
> "The center of the range is where price gravitates—equilibrium between bulls and bears."
|
||||
|
||||
MIDPOINT calculates the midpoint of the rolling range: (Highest + Lowest) / 2. This represents the equilibrium price level within the lookback window. Validated against TA-Lib MIDPOINT function.
|
||||
|
||||
## Historical Context
|
||||
|
||||
The range midpoint appears throughout technical analysis as a mean-reversion anchor. Donchian Channel centerlines, pivot points, and equilibrium theories all reference the arithmetic mean of high and low extremes. The concept predates computers—floor traders mentally tracked "the middle of the range" to identify fair value.
|
||||
|
||||
The calculation itself is trivial: average the maximum and minimum. The efficiency challenge lies in computing those extremes. QuanTAlib composes MIDPOINT from HIGHEST and LOWEST, each using O(1) amortized monotonic deque algorithms, yielding O(1) amortized midpoint updates.
|
||||
|
||||
## Architecture & Physics
|
||||
|
||||
### 1. Composition Pattern
|
||||
|
||||
MIDPOINT internally maintains two child indicators:
|
||||
|
||||
$$
|
||||
\text{Midpoint}_t = \frac{\text{Highest}_t + \text{Lowest}_t}{2}
|
||||
$$
|
||||
|
||||
Both HIGHEST and LOWEST use monotonic deques independently.
|
||||
|
||||
### 2. Data Flow
|
||||
|
||||
```
|
||||
Input Value
|
||||
│
|
||||
├──► Highest (monotonic decreasing deque) ──► max
|
||||
│
|
||||
└──► Lowest (monotonic increasing deque) ──► min
|
||||
│
|
||||
(max + min) × 0.5 ◄───┘
|
||||
│
|
||||
▼
|
||||
Midpoint
|
||||
```
|
||||
|
||||
### 3. State Synchronization
|
||||
|
||||
When `isNew=false`:
|
||||
1. Both child indicators receive the correction
|
||||
2. Each rebuilds its deque independently
|
||||
3. Midpoint recalculates from corrected extremes
|
||||
|
||||
State consistency is maintained through delegation.
|
||||
|
||||
## Mathematical Foundation
|
||||
|
||||
### Midpoint Definition
|
||||
|
||||
$$
|
||||
\text{Midpoint}_t = \frac{\max_{i \in [t-n+1, t]} V_i + \min_{i \in [t-n+1, t]} V_i}{2}
|
||||
$$
|
||||
|
||||
### Equivalent Formulation
|
||||
|
||||
$$
|
||||
\text{Midpoint}_t = \text{Lowest}_t + \frac{\text{Range}_t}{2}
|
||||
$$
|
||||
|
||||
where $\text{Range}_t = \text{Highest}_t - \text{Lowest}_t$.
|
||||
|
||||
### Properties
|
||||
|
||||
- **Bounds**: $\text{Lowest}_t \leq \text{Midpoint}_t \leq \text{Highest}_t$
|
||||
- **Symmetry**: Equidistant from both extremes by definition
|
||||
- **Range relationship**: $\text{Midpoint} = \text{Lowest} + 0.5 \times \text{Range}$
|
||||
|
||||
## Performance Profile
|
||||
|
||||
### Operation Count (Streaming Mode, Amortized)
|
||||
|
||||
| Operation | Count | Cost (cycles) | Subtotal |
|
||||
| :--- | :---: | :---: | :---: |
|
||||
| Highest update | 1 | ~14 | 14 |
|
||||
| Lowest update | 1 | ~14 | 14 |
|
||||
| ADD | 1 | 1 | 1 |
|
||||
| MUL | 1 | 3 | 3 |
|
||||
| **Total** | **4** | — | **~32 cycles** |
|
||||
|
||||
### Batch Mode Optimization
|
||||
|
||||
The span-based Calculate method can:
|
||||
1. Compute HIGHEST for entire series
|
||||
2. Compute LOWEST for entire series
|
||||
3. Vectorize `(high[i] + low[i]) * 0.5` using SIMD
|
||||
|
||||
Steps 1-2 are sequential (deque-based), but step 3 is embarrassingly parallel.
|
||||
|
||||
### Quality Metrics
|
||||
|
||||
| Metric | Score | Notes |
|
||||
| :--- | :---: | :--- |
|
||||
| **Accuracy** | 10/10 | Exact arithmetic |
|
||||
| **Timeliness** | 8/10 | Lags turning points |
|
||||
| **Smoothness** | 4/10 | Smoother than raw H/L but still stepped |
|
||||
| **Computational Cost** | 9/10 | 2× deque overhead |
|
||||
| **Memory** | 6/10 | 2× buffer memory |
|
||||
|
||||
## Validation
|
||||
|
||||
| Library | Status | Notes |
|
||||
| :--- | :---: | :--- |
|
||||
| **TA-Lib MIDPOINT** | ✅ | Exact match |
|
||||
| **Internal Consistency** | ✅ | (Highest + Lowest) / 2 verified |
|
||||
| **Known Values** | ✅ | Manual verification |
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Not a Moving Average**: MIDPOINT tracks range center, not price average. A trending series may have midpoint above or below mean price.
|
||||
|
||||
2. **Step Changes**: When either extreme expires from the window, midpoint jumps. This creates non-smooth transitions.
|
||||
|
||||
3. **Double Memory**: Maintains two ring buffers internally. For period=200: ~6.4KB total.
|
||||
|
||||
4. **Mean-Reversion Assumption**: Using midpoint as "fair value" assumes bounded ranges. In trending markets, price may stay above/below midpoint for extended periods.
|
||||
|
||||
5. **Warmup Period**: `IsHot` becomes true after `period` values. Before warmup, computes midpoint of available data.
|
||||
|
||||
6. **Difference from MEDPRICE**: MIDPOINT uses rolling H/L extremes. TA-Lib MEDPRICE uses single-bar (High + Low) / 2. These are distinct calculations.
|
||||
|
||||
## References
|
||||
|
||||
- Donchian, Richard D. (1960). "High Finance in Copper." Financial Analysts Journal.
|
||||
- TA-Lib: MIDPOINT function documentation.
|
||||
- Murphy, John J. (1999). "Technical Analysis of the Financial Markets." New York Institute of Finance.
|
||||
@@ -1,28 +0,0 @@
|
||||
// The MIT License (MIT)
|
||||
// © mihakralj
|
||||
//@version=6
|
||||
indicator("Midpoint (MIDPOINT)", "MIDPOINT", overlay=true)
|
||||
|
||||
//@function Calculates the midpoint of the highest high and lowest low over a specified period
|
||||
//@param src Source series to calculate midpoint for
|
||||
//@param len Lookback period for finding highest and lowest values
|
||||
//@returns float The midpoint value (highest + lowest) * 0.5 over the period
|
||||
//@optimized Uses multiplication instead of division for performance
|
||||
midpoint(series float src, simple int len) =>
|
||||
if len <= 0
|
||||
runtime.error("Length must be greater than 0")
|
||||
float highest_val = ta.highest(src, len)
|
||||
float lowest_val = ta.lowest(src, len)
|
||||
(highest_val + lowest_val) * 0.5
|
||||
|
||||
// ---------- Main loop ----------
|
||||
|
||||
// Inputs
|
||||
i_source = input.source(close, "Source")
|
||||
i_length = input.int(14, "Length", minval=1)
|
||||
|
||||
// Calculation
|
||||
result = midpoint(i_source, i_length)
|
||||
|
||||
// Plot
|
||||
plot(result, "Midpoint", color=color.yellow, linewidth=2)
|
||||
@@ -1,216 +0,0 @@
|
||||
using Xunit;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class StandardizeIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void StandardizeIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new StandardizeIndicator();
|
||||
|
||||
Assert.Equal(SourceType.Close, indicator.Source);
|
||||
Assert.Equal(20, indicator.Period);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("STANDARDIZE - Z-Score Normalization", indicator.Name);
|
||||
Assert.True(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StandardizeIndicator_MinHistoryDepths_EqualsPeriod()
|
||||
{
|
||||
var indicator = new StandardizeIndicator { Period = 30 };
|
||||
Assert.Equal(30, indicator.MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StandardizeIndicator_ShortName_IncludesPeriod()
|
||||
{
|
||||
var indicator = new StandardizeIndicator { Period = 10 };
|
||||
Assert.Equal("STND(10)", indicator.ShortName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StandardizeIndicator_Initialize_CreatesLineSeries()
|
||||
{
|
||||
var indicator = new StandardizeIndicator();
|
||||
indicator.Initialize();
|
||||
|
||||
Assert.Single(indicator.LinesSeries);
|
||||
Assert.Equal("Z-Score", indicator.LinesSeries[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StandardizeIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new StandardizeIndicator { Period = 3 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
indicator.HistoricalData.AddBar(now, 10, 15, 5, 10);
|
||||
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
|
||||
// Single bar: not enough data for stdev, expect 0
|
||||
Assert.Equal(0.0, indicator.LinesSeries[0].GetValue(0), 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StandardizeIndicator_ProcessUpdate_NewBar_ComputesValue()
|
||||
{
|
||||
var indicator = new StandardizeIndicator { Period = 3 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
// Add bars with varying close values: 2, 4, 6
|
||||
indicator.HistoricalData.AddBar(now, 2, 3, 1, 2);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(1), 4, 5, 3, 4);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(2), 6, 7, 5, 6);
|
||||
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
Assert.Equal(3, indicator.LinesSeries[0].Count);
|
||||
// Last value should be finite (indicator is computing z-score)
|
||||
double lastValue = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(lastValue), $"Z-score should be finite, got {lastValue}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StandardizeIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
|
||||
{
|
||||
var indicator = new StandardizeIndicator { Period = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
indicator.HistoricalData.AddBar(now, 10, 15, 5, 10);
|
||||
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
|
||||
|
||||
Assert.Equal(2, indicator.LinesSeries[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StandardizeIndicator_OutputIsFinite()
|
||||
{
|
||||
var indicator = new StandardizeIndicator { Period = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
// Add various bars
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), i * 10, i * 10 + 5, i * 10 - 5, i * 10);
|
||||
indicator.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar));
|
||||
}
|
||||
|
||||
// All z-score values should be finite
|
||||
for (int i = 0; i < indicator.LinesSeries[0].Count; i++)
|
||||
{
|
||||
double val = indicator.LinesSeries[0].GetValue(i);
|
||||
Assert.True(double.IsFinite(val), $"Value {val} at index {i} is not finite");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StandardizeIndicator_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 StandardizeIndicator { Source = source, Period = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 10 + i, 20 + i, 5 + i, 15 + i);
|
||||
indicator.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar));
|
||||
}
|
||||
|
||||
Assert.Equal(5, indicator.LinesSeries[0].Count);
|
||||
double val = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(val), $"Source {source}: value {val} is not finite");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StandardizeIndicator_DifferentPeriods_Work()
|
||||
{
|
||||
var periods = new[] { 2, 5, 14, 50, 100 };
|
||||
|
||||
foreach (var period in periods)
|
||||
{
|
||||
var indicator = new StandardizeIndicator { Period = period };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < period + 5; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), i, i + 1, i - 1, i);
|
||||
indicator.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar));
|
||||
}
|
||||
|
||||
Assert.Equal(period + 5, indicator.LinesSeries[0].Count);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StandardizeIndicator_MeanValue_ReturnsZero()
|
||||
{
|
||||
var indicator = new StandardizeIndicator { Period = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
// Create symmetric pattern around 50
|
||||
indicator.HistoricalData.AddBar(now, 30, 35, 25, 30);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(1), 40, 45, 35, 40);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(2), 60, 65, 55, 60);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(3), 70, 75, 65, 70);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(4), 50, 55, 45, 50); // Mean
|
||||
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
// Last value = 50 = mean of [30, 40, 60, 70, 50] = 250/5 = 50
|
||||
// Z-score should be 0
|
||||
Assert.Equal(0.0, indicator.LinesSeries[0].GetValue(0), 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StandardizeIndicator_FlatData_ReturnsZero()
|
||||
{
|
||||
var indicator = new StandardizeIndicator { Period = 3 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
// All same close values
|
||||
indicator.HistoricalData.AddBar(now, 100, 105, 95, 100);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(1), 100, 105, 95, 100);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(2), 100, 105, 95, 100);
|
||||
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
// Flat data: stdev = 0, should return 0
|
||||
Assert.Equal(0.0, indicator.LinesSeries[0].GetValue(0), 1e-10);
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
using System.Drawing;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
using static QuanTAlib.IndicatorExtensions;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// STANDARDIZE (Z-Score Normalization) Quantower indicator.
|
||||
/// Calculates the z-score of values over a lookback period using sample standard deviation.
|
||||
/// </summary>
|
||||
public class StandardizeIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[DataSourceInput]
|
||||
public SourceType Source { get; set; } = SourceType.Close;
|
||||
|
||||
[InputParameter("Period", sortIndex: 0, minimum: 2, maximum: 1000, increment: 1)]
|
||||
public int Period { get; set; } = 20;
|
||||
|
||||
[InputParameter("Show Cold Values", sortIndex: 100)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Standardize? _standardize;
|
||||
private Func<IHistoryItem, double>? _selector;
|
||||
|
||||
public int MinHistoryDepths => Period;
|
||||
public override string ShortName => $"STND({Period})";
|
||||
|
||||
public StandardizeIndicator()
|
||||
{
|
||||
Name = "STANDARDIZE - Z-Score Normalization";
|
||||
Description = "Calculates the z-score of values over a lookback period using sample standard deviation";
|
||||
SeparateWindow = true;
|
||||
OnBackGround = true;
|
||||
}
|
||||
|
||||
protected override void OnInit()
|
||||
{
|
||||
_standardize = new Standardize(Period);
|
||||
_selector = Source.GetPriceSelector();
|
||||
|
||||
AddLineSeries(new LineSeries("Z-Score", Color.Yellow, 2, LineStyle.Solid));
|
||||
}
|
||||
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
if (_standardize == null || _selector == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var item = HistoricalData[0, SeekOriginHistory.End];
|
||||
double value = _selector(item);
|
||||
bool isNew = args.IsNewBar();
|
||||
|
||||
TValue input = new(item.TimeLeft, value);
|
||||
_standardize.Update(input, isNew);
|
||||
|
||||
bool isHot = _standardize.IsHot;
|
||||
|
||||
LinesSeries[0].SetValue(_standardize.Last.Value, isHot, ShowColdValues);
|
||||
}
|
||||
}
|
||||
@@ -1,418 +0,0 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class StandardizeTests
|
||||
{
|
||||
private readonly GBM _gbm = new(100, 0.05, 0.2, seed: 42);
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Constructor_ValidPeriod_SetsProperties()
|
||||
{
|
||||
var standardize = new Standardize(20);
|
||||
|
||||
Assert.Equal("Standardize(20)", standardize.Name);
|
||||
Assert.Equal(20, standardize.WarmupPeriod);
|
||||
Assert.False(standardize.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Constructor_InvalidPeriod_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new Standardize(1));
|
||||
Assert.Throws<ArgumentException>(() => new Standardize(0));
|
||||
Assert.Throws<ArgumentException>(() => new Standardize(-1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Constructor_Period2_IsMinimumValid()
|
||||
{
|
||||
var standardize = new Standardize(2);
|
||||
Assert.Equal("Standardize(2)", standardize.Name);
|
||||
Assert.Equal(2, standardize.WarmupPeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Update_BasicCalculation()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
// Feed values: 10, 20, 30, 40, 50
|
||||
// Mean = 30, Sample StdDev = sqrt(((10-30)^2 + (20-30)^2 + ... + (50-30)^2) / 4)
|
||||
// = sqrt((400 + 100 + 0 + 100 + 400) / 4) = sqrt(250) ≈ 15.811
|
||||
// Z-score of 50: (50 - 30) / 15.811 ≈ 1.265
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 10));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 20));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 30));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 40));
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
|
||||
double expectedStdDev = Math.Sqrt(250.0); // 15.811...
|
||||
double expectedZ = (50 - 30) / expectedStdDev; // ≈ 1.265
|
||||
|
||||
Assert.Equal(expectedZ, result.Value, 1e-6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Update_MeanValueReturnsZero()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
// Values with known pattern
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 100));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
|
||||
// Mean = (0 + 100 + 50 + 50 + 50) / 5 = 50
|
||||
// Value 50 = mean, so z-score = 0
|
||||
Assert.Equal(0.0, result.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Update_NegativeZScore()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
// Feed ascending values, then test below mean
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 10));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 20));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 30));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 40));
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, 10));
|
||||
|
||||
// Mean of [10, 20, 30, 40, 10] = 22
|
||||
// Value 10 < mean, so z-score should be negative
|
||||
Assert.True(result.Value < 0, "Z-score should be negative for below-mean value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Update_PositiveZScore()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
// Feed descending values, then test above mean
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 40));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 30));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 20));
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
|
||||
// Value 50 > mean, so z-score should be positive
|
||||
Assert.True(result.Value > 0, "Z-score should be positive for above-mean value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Update_FlatRange_ReturnsZero()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
// All same values
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 100));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 100));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 100));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 100));
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, 100));
|
||||
|
||||
// Flat data: stdev = 0, value = mean, so z-score = 0
|
||||
Assert.Equal(0.0, result.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Update_IsNew_False_RollsBack()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 100));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
|
||||
var result1 = standardize.Update(new TValue(DateTime.UtcNow, 25), isNew: true);
|
||||
var result2 = standardize.Update(new TValue(DateTime.UtcNow, 75), isNew: false);
|
||||
|
||||
// Different values should give different z-scores
|
||||
Assert.NotEqual(result1.Value, result2.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Update_NaN_UsesLastValid()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 100));
|
||||
var valid = standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
|
||||
var nanResult = standardize.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
|
||||
Assert.Equal(valid.Value, nanResult.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Update_Infinity_UsesLastValid()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 100));
|
||||
var valid = standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
|
||||
var infResult = standardize.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
|
||||
|
||||
Assert.Equal(valid.Value, infResult.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_IsHot_BecomesTrue_AfterWarmup()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
standardize.Update(new TValue(DateTime.UtcNow, i * 10));
|
||||
Assert.False(standardize.IsHot);
|
||||
}
|
||||
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 40));
|
||||
Assert.True(standardize.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Reset_ClearsState()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
standardize.Update(new TValue(DateTime.UtcNow, i * 10));
|
||||
}
|
||||
|
||||
Assert.True(standardize.IsHot);
|
||||
|
||||
standardize.Reset();
|
||||
|
||||
Assert.False(standardize.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_OutputIsFinite()
|
||||
{
|
||||
var standardize = new Standardize(20);
|
||||
var series = _gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in series)
|
||||
{
|
||||
var result = standardize.Update(new TValue(bar.Time, bar.Close));
|
||||
Assert.True(double.IsFinite(result.Value),
|
||||
$"Standardize output {result.Value} should be finite");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_OutputTypicallyInReasonableRange()
|
||||
{
|
||||
var standardize = new Standardize(20);
|
||||
var series = _gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
int extremeCount = 0;
|
||||
foreach (var bar in series)
|
||||
{
|
||||
var result = standardize.Update(new TValue(bar.Time, bar.Close));
|
||||
// Most z-scores should be within ±4 for normal data
|
||||
if (Math.Abs(result.Value) > 4)
|
||||
{
|
||||
extremeCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Allow up to 5% extreme values
|
||||
Assert.True(extremeCount < 25, $"Too many extreme z-scores: {extremeCount}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Chaining_WorksCorrectly()
|
||||
{
|
||||
var source = new TSeries();
|
||||
var standardize = new Standardize(source, 10);
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
source.Add(new TValue(DateTime.UtcNow.AddSeconds(i), i * 5));
|
||||
}
|
||||
|
||||
Assert.True(standardize.IsHot);
|
||||
// Last value in a linear sequence should have positive z-score
|
||||
Assert.True(standardize.Last.Value > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_StaticCalculate_TSeries_MatchesStreaming()
|
||||
{
|
||||
var series = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var tseries = new TSeries();
|
||||
foreach (var bar in series)
|
||||
{
|
||||
tseries.Add(new TValue(bar.Time, bar.Close), true);
|
||||
}
|
||||
|
||||
// Static calculation
|
||||
var staticResult = Standardize.Batch(tseries, 14);
|
||||
|
||||
// Streaming calculation
|
||||
var streamStandardize = new Standardize(14);
|
||||
var streamResult = new TSeries();
|
||||
foreach (var bar in series)
|
||||
{
|
||||
streamResult.Add(streamStandardize.Update(new TValue(bar.Time, bar.Close)), true);
|
||||
}
|
||||
|
||||
// Compare last 50 values
|
||||
for (int i = 50; i < 100; i++)
|
||||
{
|
||||
Assert.Equal(staticResult[i].Value, streamResult[i].Value, 1e-10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_StaticCalculate_Span_MatchesStreaming()
|
||||
{
|
||||
var series = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
double[] values = series.Select(b => b.Close).ToArray();
|
||||
double[] output = new double[values.Length];
|
||||
|
||||
// Span calculation
|
||||
Standardize.Batch(values, output, 14);
|
||||
|
||||
// Streaming calculation
|
||||
var standardize = new Standardize(14);
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, values[i]));
|
||||
Assert.Equal(output[i], result.Value, 1e-10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_StaticCalculate_Span_ValidatesParameters()
|
||||
{
|
||||
double[] source = [1, 2, 3, 4, 5];
|
||||
double[] output = new double[5];
|
||||
|
||||
Assert.Throws<ArgumentException>(() => Standardize.Batch([], output));
|
||||
Assert.Throws<ArgumentException>(() => Standardize.Batch(source, new double[3]));
|
||||
Assert.Throws<ArgumentException>(() => Standardize.Batch(source, output, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_RollingWindow_AdaptsToNewData()
|
||||
{
|
||||
var standardize = new Standardize(3);
|
||||
|
||||
// Feed: 0, 50, 100 -> window complete
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 100));
|
||||
|
||||
// Mean = 50, value = 100, should be positive z-score
|
||||
Assert.True(standardize.Last.Value > 0);
|
||||
|
||||
// Now feed 0, window becomes [50, 100, 0]
|
||||
// Mean = 50, value = 0, should be negative z-score
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
Assert.True(result.Value < 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_SampleStdDev_UsesN_Minus_1()
|
||||
{
|
||||
var standardize = new Standardize(3);
|
||||
|
||||
// Values: 2, 4, 6
|
||||
// Mean = 4
|
||||
// Sum of squared deviations = (2-4)² + (4-4)² + (6-4)² = 4 + 0 + 4 = 8
|
||||
// Sample variance = 8 / (3-1) = 4
|
||||
// Sample StdDev = 2
|
||||
// Z-score of 6: (6 - 4) / 2 = 1
|
||||
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 2));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 4));
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, 6));
|
||||
|
||||
Assert.Equal(1.0, result.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Symmetry_PositiveAndNegative()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
// Create symmetric distribution around 50
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 30));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 40));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 60));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 70));
|
||||
// Mean = 50, StdDev = sqrt(200)
|
||||
|
||||
// Now test symmetry
|
||||
standardize.Reset();
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 30));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 40));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 60));
|
||||
var zPositive = standardize.Update(new TValue(DateTime.UtcNow, 70)); // Above mean
|
||||
|
||||
standardize.Reset();
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 70));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 60));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 40));
|
||||
var zNegative = standardize.Update(new TValue(DateTime.UtcNow, 30)); // Below mean
|
||||
|
||||
// Symmetric: |z(70)| should equal |z(30)|
|
||||
Assert.Equal(Math.Abs(zPositive.Value), Math.Abs(zNegative.Value), 1e-10);
|
||||
Assert.True(zPositive.Value > 0, "Z-score for above-mean value should be positive");
|
||||
Assert.True(zNegative.Value < 0, "Z-score for below-mean value should be negative");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_NegativeValues_WorksCorrectly()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
// Range from -100 to +100
|
||||
standardize.Update(new TValue(DateTime.UtcNow, -100));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, -50));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 100));
|
||||
|
||||
// Mean = 0, so z-score of 100 should be positive and equal to z-score of 0
|
||||
// z = (100 - 0) / stdev
|
||||
Assert.True(standardize.Last.Value > 0);
|
||||
|
||||
// Test zero: should have z-score of 0
|
||||
standardize.Reset();
|
||||
standardize.Update(new TValue(DateTime.UtcNow, -100));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, -50));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 100));
|
||||
var zeroResult = standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
Assert.Equal(0.0, zeroResult.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Prime_WorksCorrectly()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
double[] primeData = [10, 20, 30, 40, 50];
|
||||
standardize.Prime(primeData);
|
||||
|
||||
Assert.True(standardize.IsHot);
|
||||
// After prime, should have valid z-score
|
||||
Assert.True(double.IsFinite(standardize.Last.Value));
|
||||
}
|
||||
}
|
||||
@@ -1,423 +0,0 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Validation tests for Standardize indicator.
|
||||
/// Since Standardize is a basic mathematical transformation (z-score), validation focuses on
|
||||
/// mathematical properties rather than external library comparison.
|
||||
/// </summary>
|
||||
public class StandardizeValidationTests
|
||||
{
|
||||
private readonly GBM _gbm = new(100, 0.05, 0.2, seed: 42);
|
||||
|
||||
[Fact]
|
||||
public void Standardize_OutputIsFinite_AllPeriods()
|
||||
{
|
||||
// Test across multiple periods and data sets
|
||||
int[] periods = { 5, 14, 50, 100 };
|
||||
|
||||
foreach (var period in periods)
|
||||
{
|
||||
var standardize = new Standardize(period);
|
||||
var series = _gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in series)
|
||||
{
|
||||
var result = standardize.Update(new TValue(bar.Time, bar.Close));
|
||||
Assert.True(double.IsFinite(result.Value),
|
||||
$"Period {period}: output {result.Value} is not finite");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_MeanValue_ReturnsZero()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
// Create data where all values equal the mean
|
||||
double[] values = [50, 50, 50, 50, 50];
|
||||
|
||||
foreach (var v in values)
|
||||
{
|
||||
standardize.Update(new TValue(DateTime.UtcNow, v));
|
||||
}
|
||||
|
||||
// Value = mean, stdev = 0, should return 0
|
||||
Assert.Equal(0.0, standardize.Last.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_OneStdDevAboveMean_ReturnsOne()
|
||||
{
|
||||
// For a known distribution, verify z-score calculation
|
||||
// Values: 2, 4, 6 -> Mean = 4, Sample StdDev = 2
|
||||
// Z-score of 6 = (6 - 4) / 2 = 1
|
||||
|
||||
var standardize = new Standardize(3);
|
||||
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 2));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 4));
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, 6));
|
||||
|
||||
Assert.Equal(1.0, result.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_OneStdDevBelowMean_ReturnsNegativeOne()
|
||||
{
|
||||
// Values: 6, 4, 2 -> Mean = 4, Sample StdDev = 2
|
||||
// Z-score of 2 = (2 - 4) / 2 = -1
|
||||
|
||||
var standardize = new Standardize(3);
|
||||
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 6));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 4));
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, 2));
|
||||
|
||||
Assert.Equal(-1.0, result.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_TwoStdDevsAboveMean_ReturnsTwo()
|
||||
{
|
||||
// Values: 0, 4, 8 -> Mean = 4, Sample StdDev = 4
|
||||
// Z-score of 12 = (12 - 4) / 4 = 2
|
||||
|
||||
var standardize = new Standardize(3);
|
||||
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 4));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 8));
|
||||
|
||||
// Now add 12 to the window
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, 12));
|
||||
// Window is now [4, 8, 12], Mean = 8, StdDev = 4
|
||||
// Z-score = (12 - 8) / 4 = 1.0
|
||||
|
||||
Assert.Equal(1.0, result.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_ManualCalculation_Matches()
|
||||
{
|
||||
// Manual calculation test
|
||||
var standardize = new Standardize(4);
|
||||
|
||||
double[] values = [10, 20, 30, 40];
|
||||
|
||||
foreach (var v in values)
|
||||
{
|
||||
standardize.Update(new TValue(DateTime.UtcNow, v));
|
||||
}
|
||||
|
||||
// Mean = (10 + 20 + 30 + 40) / 4 = 25
|
||||
// Sum of squared deviations = (10-25)² + (20-25)² + (30-25)² + (40-25)²
|
||||
// = 225 + 25 + 25 + 225 = 500
|
||||
// Sample variance = 500 / 3 = 166.667
|
||||
// Sample StdDev = sqrt(166.667) ≈ 12.91
|
||||
// Z-score of 40 = (40 - 25) / 12.91 ≈ 1.162
|
||||
|
||||
double mean = 25.0;
|
||||
double sampleVariance = 500.0 / 3.0;
|
||||
double sampleStdDev = Math.Sqrt(sampleVariance);
|
||||
double expectedZ = (40.0 - mean) / sampleStdDev;
|
||||
|
||||
Assert.Equal(expectedZ, standardize.Last.Value, 1e-6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Symmetry_OppositeSignsForSymmetricValues()
|
||||
{
|
||||
// For symmetric values around the mean, z-scores should be opposite
|
||||
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
// Window: -20, -10, 0, 10, 20 -> Mean = 0
|
||||
standardize.Update(new TValue(DateTime.UtcNow, -20));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, -10));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 10));
|
||||
var zFor20 = standardize.Update(new TValue(DateTime.UtcNow, 20));
|
||||
|
||||
// Window: 20, 10, 0, -10, -20 -> Mean = 0
|
||||
standardize.Reset();
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 20));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 10));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, -10));
|
||||
var zForMinus20 = standardize.Update(new TValue(DateTime.UtcNow, -20));
|
||||
|
||||
// |z(20)| should equal |z(-20)| and have opposite signs
|
||||
Assert.Equal(Math.Abs(zFor20.Value), Math.Abs(zForMinus20.Value), 1e-10);
|
||||
Assert.True(zFor20.Value > 0);
|
||||
Assert.True(zForMinus20.Value < 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_RollingWindow_AdaptsToNewData()
|
||||
{
|
||||
var standardize = new Standardize(3);
|
||||
|
||||
// Initial window: 0, 50, 100
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 100));
|
||||
|
||||
// Mean = 50, value = 100 is above mean
|
||||
Assert.True(standardize.Last.Value > 0);
|
||||
|
||||
// Add 0, window becomes [50, 100, 0]
|
||||
// Mean = 50, value = 0 is below mean
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
Assert.True(result.Value < 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_NegativeValues_WorksCorrectly()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
// All negative values
|
||||
standardize.Update(new TValue(DateTime.UtcNow, -100));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, -75));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, -50));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, -25));
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
|
||||
// 0 is above the mean of negative values
|
||||
Assert.True(result.Value > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_LargeValues_StillPrecise()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
// Use larger differences to avoid floating-point precision issues
|
||||
double baseVal = 1e6; // Smaller base, larger differences
|
||||
standardize.Update(new TValue(DateTime.UtcNow, baseVal - 200));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, baseVal - 100));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, baseVal));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, baseVal + 100));
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, baseVal + 200));
|
||||
|
||||
// Mean = baseVal, should still give reasonable z-score
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
Assert.True(result.Value > 0, $"Expected positive z-score for above-mean value, got {result.Value}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_SmallDifferences_StillPrecise()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
// Very small differences
|
||||
double baseVal = 100.0;
|
||||
double epsilon = 1e-8;
|
||||
|
||||
standardize.Update(new TValue(DateTime.UtcNow, baseVal));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, baseVal + epsilon));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, baseVal + 2 * epsilon));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, baseVal + 3 * epsilon));
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, baseVal + 4 * epsilon));
|
||||
|
||||
// Should be finite and reasonable
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_StreamingVsBatch_Match()
|
||||
{
|
||||
var series = _gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
double[] values = series.Select(b => b.Close).ToArray();
|
||||
|
||||
// Streaming
|
||||
var streamStandardize = new Standardize(14);
|
||||
double[] streamResults = new double[values.Length];
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
streamResults[i] = streamStandardize.Update(new TValue(DateTime.UtcNow, values[i])).Value;
|
||||
}
|
||||
|
||||
// Batch
|
||||
double[] batchResults = new double[values.Length];
|
||||
Standardize.Batch(values, batchResults, 14);
|
||||
|
||||
// Compare all values
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
Assert.Equal(batchResults[i], streamResults[i], 1e-10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_AllModes_Consistent()
|
||||
{
|
||||
var series = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
int period = 14;
|
||||
|
||||
// Mode 1: Streaming via Update(TValue)
|
||||
var standardize1 = new Standardize(period);
|
||||
var results1 = new List<double>();
|
||||
foreach (var bar in series)
|
||||
{
|
||||
results1.Add(standardize1.Update(new TValue(bar.Time, bar.Close)).Value);
|
||||
}
|
||||
|
||||
// Mode 2: Batch via Update(TSeries)
|
||||
var tseries = new TSeries();
|
||||
foreach (var bar in series)
|
||||
{
|
||||
tseries.Add(new TValue(bar.Time, bar.Close), true);
|
||||
}
|
||||
|
||||
var results2 = Standardize.Batch(tseries, period);
|
||||
|
||||
// Mode 3: Static span Calculate
|
||||
double[] values = series.Select(b => b.Close).ToArray();
|
||||
double[] results3 = new double[values.Length];
|
||||
Standardize.Batch(values, results3, period);
|
||||
|
||||
// Mode 4: Event-based chaining
|
||||
var source = new TSeries();
|
||||
var standardize4 = new Standardize(source, period);
|
||||
foreach (var bar in series)
|
||||
{
|
||||
source.Add(new TValue(bar.Time, bar.Close), true);
|
||||
}
|
||||
|
||||
double results4 = standardize4.Last.Value;
|
||||
|
||||
// Compare all modes (use last 50 values for stability)
|
||||
for (int i = 50; i < 100; i++)
|
||||
{
|
||||
Assert.Equal(results1[i], results2[i].Value, 1e-10);
|
||||
Assert.Equal(results1[i], results3[i], 1e-10);
|
||||
}
|
||||
// Verify Mode 4 matches last value from other modes
|
||||
Assert.Equal(results1[^1], results4, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_BarCorrection_WorksCorrectly()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
// Build up buffer
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 100));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
|
||||
// New bar
|
||||
var first = standardize.Update(new TValue(DateTime.UtcNow, 75), isNew: true);
|
||||
|
||||
// Correction (same bar, different value)
|
||||
var corrected = standardize.Update(new TValue(DateTime.UtcNow, 25), isNew: false);
|
||||
|
||||
// Values should be different
|
||||
Assert.NotEqual(first.Value, corrected.Value);
|
||||
|
||||
// Further correction should still work
|
||||
var corrected2 = standardize.Update(new TValue(DateTime.UtcNow, 50), isNew: false);
|
||||
Assert.NotEqual(corrected.Value, corrected2.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Period2_IsMinimum()
|
||||
{
|
||||
var standardize = new Standardize(2);
|
||||
|
||||
// With only 2 values, sample stdev is still meaningful
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, 100));
|
||||
|
||||
// Mean = 50, Sample StdDev = sqrt(((0-50)² + (100-50)²) / 1) = sqrt(5000) ≈ 70.71
|
||||
// Z-score of 100 = (100 - 50) / 70.71 ≈ 0.707
|
||||
double mean = 50.0;
|
||||
double sampleVariance = (2500.0 + 2500.0) / 1.0; // N-1 = 1
|
||||
double sampleStdDev = Math.Sqrt(sampleVariance);
|
||||
double expectedZ = (100.0 - mean) / sampleStdDev;
|
||||
|
||||
Assert.Equal(expectedZ, result.Value, 1e-6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_VeryLargePeriod_StillWorks()
|
||||
{
|
||||
var standardize = new Standardize(1000);
|
||||
var series = _gbm.Fetch(1500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in series)
|
||||
{
|
||||
var result = standardize.Update(new TValue(bar.Time, bar.Close));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
Assert.True(standardize.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_ZScoreDistribution_ReasonableForFinancialData()
|
||||
{
|
||||
var standardize = new Standardize(50);
|
||||
var series = _gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
var zScores = new List<double>();
|
||||
foreach (var bar in series)
|
||||
{
|
||||
var result = standardize.Update(new TValue(bar.Time, bar.Close));
|
||||
if (standardize.IsHot)
|
||||
{
|
||||
zScores.Add(result.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// For financial data (GBM returns lognormal data), the 68% rule doesn't apply directly
|
||||
// However, most z-scores should still be within reasonable bounds (±3)
|
||||
int withinThreeStdDev = zScores.Count(z => Math.Abs(z) <= 3);
|
||||
double ratio = (double)withinThreeStdDev / zScores.Count;
|
||||
|
||||
// At least 90% should be within ±3 for any reasonable distribution
|
||||
Assert.True(ratio > 0.90,
|
||||
$"Expected >90% of z-scores within ±3, got {ratio * 100:F1}%");
|
||||
|
||||
// Verify z-scores are reasonably distributed (not all extreme)
|
||||
int moderate = zScores.Count(z => Math.Abs(z) <= 2);
|
||||
double moderateRatio = (double)moderate / zScores.Count;
|
||||
|
||||
Assert.True(moderateRatio > 0.70,
|
||||
$"Expected >70% of z-scores within ±2, got {moderateRatio * 100:F1}%");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_SampleVsPopulationStdDev_UsesSample()
|
||||
{
|
||||
// Verify Bessel's correction (N-1) is used, not N
|
||||
|
||||
var standardize = new Standardize(4);
|
||||
|
||||
// Values: 10, 20, 30, 40
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 10));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 20));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 30));
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, 40));
|
||||
|
||||
// Mean = 25
|
||||
// Population variance = ((10-25)² + (20-25)² + (30-25)² + (40-25)²) / 4 = 500/4 = 125
|
||||
// Sample variance = 500 / 3 = 166.667
|
||||
|
||||
double mean = 25.0;
|
||||
double popStdDev = Math.Sqrt(125.0);
|
||||
double sampleStdDev = Math.Sqrt(500.0 / 3.0);
|
||||
|
||||
double zWithPopulation = (40.0 - mean) / popStdDev;
|
||||
double zWithSample = (40.0 - mean) / sampleStdDev;
|
||||
|
||||
// Result should match sample (N-1) calculation, NOT population (N)
|
||||
Assert.Equal(zWithSample, result.Value, 1e-10);
|
||||
Assert.NotEqual(zWithPopulation, result.Value);
|
||||
}
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
// STANDARDIZE: Z-Score Normalization
|
||||
// Calculates the z-score (standard score) of values over a lookback period
|
||||
// Formula: z = (x - μ) / σ where σ uses sample standard deviation (N-1)
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// STANDARDIZE: Z-Score Normalization
|
||||
/// Calculates the z-score of values over a lookback period using sample standard deviation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Key properties:
|
||||
/// - Output is unbounded (can be any real number, typically -3 to +3 for normal data)
|
||||
/// - Uses sample standard deviation (Bessel's correction, N-1 denominator)
|
||||
/// - Requires period >= 2 for meaningful standard deviation calculation
|
||||
/// - When stdev is zero (flat data), returns 0 if value equals mean, NaN otherwise
|
||||
/// - Commonly used for anomaly detection and inter-series comparison
|
||||
///
|
||||
/// Formula: z = (x - mean) / sample_stdev
|
||||
/// where sample_stdev = sqrt(sum((x_i - mean)^2) / (N - 1))
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Standardize : AbstractBase
|
||||
{
|
||||
private readonly int _period;
|
||||
private readonly RingBuffer _buffer;
|
||||
|
||||
// Welford's online algorithm state for numerical stability
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private record struct State(double LastValidZScore, double Sum, double SumSq, int ValidCount);
|
||||
private State _state, _p_state;
|
||||
|
||||
public override bool IsHot => _buffer.Count >= _period;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new Standardize indicator with specified lookback period.
|
||||
/// </summary>
|
||||
/// <param name="period">Lookback period for z-score calculation (default 20, must be >= 2)</param>
|
||||
public Standardize(int period = 20)
|
||||
{
|
||||
if (period < 2)
|
||||
{
|
||||
throw new ArgumentException("Period must be >= 2 for sample standard deviation", nameof(period));
|
||||
}
|
||||
|
||||
_period = period;
|
||||
_buffer = new RingBuffer(period);
|
||||
Name = $"Standardize({period})";
|
||||
WarmupPeriod = period;
|
||||
_state = new State(0.0, 0.0, 0.0, 0);
|
||||
_p_state = _state;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new Standardize indicator with source for event-based chaining.
|
||||
/// </summary>
|
||||
/// <param name="source">Source indicator for chaining</param>
|
||||
/// <param name="period">Lookback period (default 20)</param>
|
||||
public Standardize(ITValuePublisher source, int period = 20) : this(period)
|
||||
{
|
||||
source.Pub += HandleUpdate;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void HandleUpdate(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public override TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
if (isNew)
|
||||
{
|
||||
_p_state = _state;
|
||||
}
|
||||
else
|
||||
{
|
||||
_state = _p_state;
|
||||
}
|
||||
|
||||
double value = input.Value;
|
||||
double result;
|
||||
|
||||
if (double.IsFinite(value))
|
||||
{
|
||||
_buffer.Add(value, isNew);
|
||||
|
||||
// Compute mean and sample variance from buffer
|
||||
ReadOnlySpan<double> data = _buffer.GetSpan();
|
||||
int n = data.Length;
|
||||
|
||||
if (n < 2)
|
||||
{
|
||||
// Not enough data for sample stdev
|
||||
result = 0.0;
|
||||
_state = new State(result, value, value * value, 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Calculate sum and sum of squares
|
||||
double sum = 0.0;
|
||||
double sumSq = 0.0;
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
double v = data[i];
|
||||
sum += v;
|
||||
sumSq += v * v;
|
||||
}
|
||||
|
||||
double mean = sum / n;
|
||||
|
||||
// Population variance: (sumSq / n) - mean^2
|
||||
// Sample variance: (sumSq - n * mean^2) / (n - 1) = n / (n-1) * popVar
|
||||
double popVariance = (sumSq / n) - (mean * mean);
|
||||
|
||||
// Numerical stability: clamp tiny negative values to zero
|
||||
if (popVariance < 1e-10)
|
||||
{
|
||||
popVariance = 0.0;
|
||||
}
|
||||
|
||||
double sampleVariance = popVariance * n / (n - 1);
|
||||
double stdev = Math.Sqrt(sampleVariance);
|
||||
|
||||
if (stdev > 1e-10)
|
||||
{
|
||||
result = (value - mean) / stdev;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Stdev is essentially zero - all values are the same
|
||||
// Return 0 as neutral z-score
|
||||
result = 0.0;
|
||||
}
|
||||
|
||||
_state = new State(result, sum, sumSq, n);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Invalid input - return last valid z-score
|
||||
result = _state.LastValidZScore;
|
||||
}
|
||||
|
||||
Last = new TValue(input.Time, result);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
public override TSeries Update(TSeries source)
|
||||
{
|
||||
var result = new TSeries(source.Count);
|
||||
ReadOnlySpan<double> values = source.Values;
|
||||
ReadOnlySpan<long> times = source.Times;
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
var tv = Update(new TValue(new DateTime(times[i], DateTimeKind.Utc), values[i]), true);
|
||||
result.Add(tv, true);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
|
||||
{
|
||||
TimeSpan interval = step ?? TimeSpan.FromSeconds(1);
|
||||
DateTime time = DateTime.UtcNow - (interval * source.Length);
|
||||
|
||||
for (int i = 0; i < source.Length; i++)
|
||||
{
|
||||
Update(new TValue(time, source[i]), true);
|
||||
time += interval;
|
||||
}
|
||||
}
|
||||
|
||||
public static TSeries Batch(TSeries source, int period = 20)
|
||||
{
|
||||
var indicator = new Standardize(period);
|
||||
return indicator.Update(source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates Z-score normalization over a span of values.
|
||||
/// </summary>
|
||||
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period = 20)
|
||||
{
|
||||
if (source.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("Source cannot be empty", nameof(source));
|
||||
}
|
||||
|
||||
if (output.Length < source.Length)
|
||||
{
|
||||
throw new ArgumentException("Output length must be >= source length", nameof(output));
|
||||
}
|
||||
|
||||
if (period < 2)
|
||||
{
|
||||
throw new ArgumentException("Period must be >= 2", nameof(period));
|
||||
}
|
||||
|
||||
double lastValid = 0.0;
|
||||
|
||||
for (int i = 0; i < source.Length; i++)
|
||||
{
|
||||
double val = source[i];
|
||||
|
||||
if (!double.IsFinite(val))
|
||||
{
|
||||
output[i] = lastValid;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine window bounds
|
||||
int start = Math.Max(0, i - period + 1);
|
||||
int n = 0;
|
||||
double sum = 0.0;
|
||||
double sumSq = 0.0;
|
||||
|
||||
// Calculate sum and count of finite values in window
|
||||
for (int j = start; j <= i; j++)
|
||||
{
|
||||
double v = source[j];
|
||||
if (double.IsFinite(v))
|
||||
{
|
||||
sum += v;
|
||||
sumSq += v * v;
|
||||
n++;
|
||||
}
|
||||
}
|
||||
|
||||
if (n < 2)
|
||||
{
|
||||
output[i] = 0.0;
|
||||
lastValid = 0.0;
|
||||
continue;
|
||||
}
|
||||
|
||||
double mean = sum / n;
|
||||
double popVariance = (sumSq / n) - (mean * mean);
|
||||
|
||||
if (popVariance < 1e-10)
|
||||
{
|
||||
popVariance = 0.0;
|
||||
}
|
||||
|
||||
double sampleVariance = popVariance * n / (n - 1);
|
||||
double stdev = Math.Sqrt(sampleVariance);
|
||||
|
||||
double result;
|
||||
if (stdev > 1e-10)
|
||||
{
|
||||
result = (val - mean) / stdev;
|
||||
}
|
||||
else
|
||||
{
|
||||
result = 0.0;
|
||||
}
|
||||
|
||||
lastValid = result;
|
||||
output[i] = result;
|
||||
}
|
||||
}
|
||||
|
||||
public static (TSeries Results, Standardize Indicator) Calculate(TSeries source, int period = 20)
|
||||
{
|
||||
var indicator = new Standardize(period);
|
||||
TSeries results = indicator.Update(source);
|
||||
return (results, indicator);
|
||||
}
|
||||
|
||||
public override void Reset()
|
||||
{
|
||||
_buffer.Clear();
|
||||
_state = new State(0.0, 0.0, 0.0, 0);
|
||||
_p_state = _state;
|
||||
Last = default;
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
# STANDARDIZE: Z-Score Normalization
|
||||
|
||||
> "How many standard deviations from the mean? That single number tells you whether a value is ordinary or extraordinary. Statistics does not care about your feelings."
|
||||
|
||||
Standardize computes the z-score (standard score) of values over a rolling lookback period, using sample standard deviation with Bessel's correction (N-1 denominator). The output is unbounded and dimensionless, expressing how far the current value deviates from the recent mean in units of standard deviation. Values beyond $\pm 2$ are statistically unusual; beyond $\pm 3$ are rare. This is the foundation of anomaly detection and cross-series comparison in quantitative analysis.
|
||||
|
||||
## Historical Context
|
||||
|
||||
Z-score normalization originates in the work of Carl Friedrich Gauss (1809) and was formalized by Karl Pearson in the 1890s. The standardization transform $z = (x - \mu) / \sigma$ is one of the most widely used operations in statistics, machine learning, and quantitative finance.
|
||||
|
||||
In technical analysis, rolling z-score standardization serves multiple purposes:
|
||||
|
||||
- **Anomaly detection**: Values with $|z| > 2$ indicate statistically significant deviations from recent behavior
|
||||
- **Cross-instrument comparison**: Z-scores are dimensionless and scale-invariant, enabling direct comparison of indicators across instruments with different price ranges and volatilities
|
||||
- **Mean-reversion signals**: Extreme z-scores suggest potential reversion to the mean
|
||||
- **Feature normalization**: Required preprocessing for machine learning models applied to financial data
|
||||
|
||||
This implementation uses direct calculation from the ring buffer rather than Welford's online algorithm, trading theoretical numerical stability for practical simplicity. For typical financial data ranges and period lengths (2-200), direct calculation provides more than sufficient precision.
|
||||
|
||||
## Architecture & Physics
|
||||
|
||||
### 1. Rolling Window
|
||||
|
||||
A ring buffer of size `period` maintains the lookback window:
|
||||
|
||||
$$
|
||||
W_t = [v_{t-n+1}, v_{t-n+2}, \ldots, v_t]
|
||||
$$
|
||||
|
||||
### 2. Mean Calculation
|
||||
|
||||
$$
|
||||
\bar{x} = \frac{1}{n} \sum_{i=1}^{n} x_i
|
||||
$$
|
||||
|
||||
### 3. Sample Standard Deviation
|
||||
|
||||
Using Bessel's correction for unbiased estimation:
|
||||
|
||||
$$
|
||||
s = \sqrt{\frac{\sum_{i=1}^{n} (x_i - \bar{x})^2}{n - 1}} = \sqrt{\frac{n}{n-1} \cdot \left(\frac{\sum x_i^2}{n} - \bar{x}^2\right)}
|
||||
$$
|
||||
|
||||
The implementation computes from sum and sum-of-squares for efficiency, clamping tiny negative variance values to zero for numerical stability.
|
||||
|
||||
### 4. Z-Score
|
||||
|
||||
$$
|
||||
z = \frac{x - \bar{x}}{s}
|
||||
$$
|
||||
|
||||
When $s = 0$ (constant data), the result is 0.0 (all values equal the mean).
|
||||
|
||||
### 5. State Management
|
||||
|
||||
The indicator extends `AbstractBase` with `record struct State` containing `LastValidZScore`, `Sum`, `SumSq`, and `ValidCount`. The `_state` / `_p_state` pattern enables bar correction rollback.
|
||||
|
||||
## Mathematical Foundation
|
||||
|
||||
### Core Formula
|
||||
|
||||
$$
|
||||
z_t = \frac{x_t - \bar{x}_W}{\hat{\sigma}_W}
|
||||
$$
|
||||
|
||||
where:
|
||||
|
||||
- $x_t$ = current value
|
||||
- $\bar{x}_W$ = mean of the rolling window $W$
|
||||
- $\hat{\sigma}_W$ = sample standard deviation of $W$
|
||||
|
||||
### Sample vs Population Standard Deviation
|
||||
|
||||
| Type | Formula | Denominator | Use |
|
||||
|------|---------|-------------|-----|
|
||||
| Population | $\sigma = \sqrt{\frac{\sum(x-\mu)^2}{N}}$ | $N$ | Known full population |
|
||||
| Sample | $s = \sqrt{\frac{\sum(x-\bar{x})^2}{N-1}}$ | $N - 1$ | Estimating from sample |
|
||||
|
||||
This implementation uses **sample** standard deviation (N-1), which is standard for rolling windows where the data represents a sample of the larger price series.
|
||||
|
||||
### Properties of Z-Scores
|
||||
|
||||
For normally distributed data:
|
||||
|
||||
| Range | Probability |
|
||||
|-------|------------|
|
||||
| $|z| \leq 1$ | 68.27% |
|
||||
| $|z| \leq 2$ | 95.45% |
|
||||
| $|z| \leq 3$ | 99.73% |
|
||||
|
||||
Financial returns are not normally distributed (fat tails), so extreme z-scores occur more frequently than the table suggests.
|
||||
|
||||
### Default Parameters
|
||||
|
||||
| Parameter | Default | Purpose |
|
||||
|-----------|---------|---------|
|
||||
| period | 20 | Rolling lookback window |
|
||||
|
||||
### Constraints
|
||||
|
||||
- `period >= 2` (minimum for sample standard deviation)
|
||||
|
||||
### Warmup
|
||||
|
||||
$$
|
||||
\text{WarmupPeriod} = \text{period}
|
||||
$$
|
||||
|
||||
## Performance Profile
|
||||
|
||||
### Operation Count (Streaming Mode)
|
||||
|
||||
| Operation | Count | Notes |
|
||||
| :--- | :---: | :--- |
|
||||
| ADD | n | Sum over buffer |
|
||||
| MUL | n | Sum of squares over buffer |
|
||||
| DIV | 3 | Mean, variance, z-score |
|
||||
| SQRT | 1 | Standard deviation |
|
||||
| SUB | 1 | x - mean |
|
||||
| **Total** | **~2n + 5 ops** | Linear in period |
|
||||
|
||||
### Batch Mode (Span-based)
|
||||
|
||||
| Operation | Complexity | Notes |
|
||||
| :--- | :---: | :--- |
|
||||
| Per-element | O(period) | Re-scan buffer for sum/sumsq |
|
||||
| Total | O(n * period) | Could be optimized to O(n) with running sums |
|
||||
| Memory | O(period) | Ring buffer |
|
||||
|
||||
### Quality Metrics
|
||||
|
||||
| Metric | Score | Notes |
|
||||
| :--- | :---: | :--- |
|
||||
| **Accuracy** | 9/10 | Direct calculation, Bessel's correction |
|
||||
| **Timeliness** | 7/10 | Responds within one period to regime changes |
|
||||
| **Smoothness** | 6/10 | Depends on input volatility |
|
||||
| **Simplicity** | 8/10 | Well-understood statistical transform |
|
||||
|
||||
## Validation
|
||||
|
||||
| Library | Status | Notes |
|
||||
| :--- | :---: | :--- |
|
||||
| **Skender** | ✅ | Matches within tolerance |
|
||||
| **TA-Lib** | N/A | No Standardize function |
|
||||
| **Tulip** | N/A | No Standardize function |
|
||||
| **Ooples** | ✅ | Matches within tolerance |
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Period must be >= 2**: Sample standard deviation requires at least 2 data points (N-1 denominator). Period of 1 throws `ArgumentException`.
|
||||
|
||||
2. **Zero standard deviation**: When all values in the window are identical, stdev is zero. The implementation returns 0.0 (the value equals the mean). Some implementations return NaN here; this one avoids propagating invalids.
|
||||
|
||||
3. **Non-normal distributions**: Z-score thresholds ($\pm 2$, $\pm 3$) assume normality. Financial returns have fat tails, so extreme z-scores are more common than Gaussian theory predicts.
|
||||
|
||||
4. **Rolling window lag**: The z-score uses a trailing window. After a regime change (e.g., volatility spike), the z-score stabilizes only after the old regime values have aged out of the window.
|
||||
|
||||
5. **Unbounded output**: Unlike bounded oscillators (0-100), z-scores can theoretically be any real number. Flash crashes or gap events can produce z-scores of $\pm 10$ or more.
|
||||
|
||||
6. **Scale invariance**: Z-scores are dimensionless. A z-score of 2.0 means the same thing whether the input is a stock price, volume, or volatility measure. This enables cross-indicator comparison.
|
||||
|
||||
7. **Not a trading signal**: A high z-score means "unusual," not "sell." In trending markets, extreme z-scores can persist as the new regime becomes the norm within the window.
|
||||
|
||||
## References
|
||||
|
||||
- Gauss, C. F. (1809). "Theoria Motus Corporum Coelestium."
|
||||
- Pearson, K. (1895). "Contributions to the Mathematical Theory of Evolution." Philosophical Transactions A.
|
||||
- Mandelbrot, B. (1963). "The Variation of Certain Speculative Prices." Journal of Business.
|
||||
- Hamilton, J. D. (1994). "Time Series Analysis." Princeton University Press.
|
||||
@@ -1,61 +0,0 @@
|
||||
// The MIT License (MIT)
|
||||
// © mihakralj
|
||||
//@version=6
|
||||
indicator("Standardization (Z-score)", "STANDARDIZE", overlay=false, precision=4)
|
||||
|
||||
//@function Calculates the Z-score of a series over a lookback period.
|
||||
//@param src series float Input data series.
|
||||
//@param len simple int Lookback period for calculating mean and standard deviation (must be > 1 for sample stdev).
|
||||
//@returns series float The Z-score of the current data point, or na if issues like insufficient data or zero stdev for a non-mean current value.
|
||||
standardize(series float src, simple int len) =>
|
||||
if len <= 1
|
||||
float(na)
|
||||
var S1 = 0.0
|
||||
var S2 = 0.0
|
||||
var N = 0
|
||||
var float[] window_data_vals = array.new_float(0)
|
||||
var bool[] window_data_is_na = array.new_bool(0)
|
||||
float x_new = src[0]
|
||||
bool x_new_is_na = na(x_new)
|
||||
if array.size(window_data_vals) == len
|
||||
float x_old_val = array.get(window_data_vals, 0)
|
||||
bool x_old_was_na = array.get(window_data_is_na, 0)
|
||||
array.shift(window_data_vals)
|
||||
array.shift(window_data_is_na)
|
||||
if not x_old_was_na
|
||||
S1 -= x_old_val
|
||||
S2 -= x_old_val * x_old_val
|
||||
N -= 1
|
||||
array.push(window_data_vals, x_new_is_na ? 0.0 : x_new)
|
||||
array.push(window_data_is_na, x_new_is_na)
|
||||
if not x_new_is_na
|
||||
S1 += x_new
|
||||
S2 += x_new * x_new
|
||||
N += 1
|
||||
float z_score = na
|
||||
if N < 2
|
||||
z_score := na
|
||||
else
|
||||
float mean_val = S1 / N
|
||||
float variance_pop = (S2 / N) - (mean_val * mean_val)
|
||||
variance_pop := variance_pop < 1e-10 ? 0.0 : variance_pop
|
||||
float variance_sample = variance_pop * N / (N - 1)
|
||||
float stdev_val = math.sqrt(variance_sample)
|
||||
if x_new_is_na
|
||||
z_score := na
|
||||
else if stdev_val > 1e-10
|
||||
z_score := (x_new - mean_val) / stdev_val
|
||||
else
|
||||
z_score := (math.abs(x_new - mean_val) < 1e-10) ? 0.0 : na
|
||||
z_score
|
||||
|
||||
// Inputs
|
||||
i_source = input.source(close, title="Source")
|
||||
i_length = input.int(20, title="Lookback Period", minval=2, tooltip="Period for mean and standard deviation. Must be at least 2 for stdev.")
|
||||
|
||||
// Calculation
|
||||
z_score_value = standardize(i_source, i_length) // Renamed for clarity
|
||||
|
||||
// Plot
|
||||
plot(z_score_value, "Z-score", color=color.yellow, linewidth=2)
|
||||
hline(0, "Zero Line", color=color.gray, linestyle=hline.style_dashed)
|
||||
Reference in New Issue
Block a user