Enhance validation tests for various indicators with external library comparisons

- Added detailed comments explaining the validation limitations for MMA and ZLEMA due to differences in algorithm implementations.
- Implemented validation tests for True Range against TALib and Tulip, ensuring directional agreement.
- Updated Ulcer Index validation to clarify differences in algorithmic approaches between QuanTAlib and Skender.
- Enhanced Ease of Movement tests to verify directional agreement with Tulip's EMV, noting differences in volume scaling.
- Expanded Klinger Volume Oscillator tests to validate against Skender and Tulip, focusing on directional agreement across multiple period configurations.
- Improved Negative Volume Index tests to compare percentage changes with Tulip, addressing differences in starting values.
- Updated Positive Volume Index tests to validate against Tulip, emphasizing percentage change comparisons.
- Enhanced Williams Accumulation/Distribution tests to verify directional agreement with Tulip, highlighting formula differences.
This commit is contained in:
Miha Kralj
2026-02-11 14:46:56 -08:00
parent 6d6259a47d
commit 75c6a9f135
51 changed files with 7893 additions and 1274 deletions
+197
View File
@@ -0,0 +1,197 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class PpoIndicatorTests
{
[Fact]
public void PpoIndicator_Constructor_SetsDefaults()
{
var indicator = new PpoIndicator();
Assert.Equal("PPO - Percentage Price Oscillator", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
Assert.Equal(12, indicator.FastPeriod);
Assert.Equal(26, indicator.SlowPeriod);
Assert.Equal(9, indicator.SignalPeriod);
}
[Fact]
public void PpoIndicator_MinHistoryDepths_IsZero()
{
var indicator = new PpoIndicator();
Assert.Equal(0, PpoIndicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void PpoIndicator_ShortName_IncludesPeriods()
{
var indicator = new PpoIndicator();
indicator.Initialize();
Assert.Equal("PPO(12,26,9):Close", indicator.ShortName);
}
[Fact]
public void PpoIndicator_SourceCodeLink_IsValid()
{
var indicator = new PpoIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Ppo.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void PpoIndicator_Initialize_CreatesThreeLineSeries()
{
var indicator = new PpoIndicator();
indicator.Initialize();
Assert.Equal(3, indicator.LinesSeries.Count);
Assert.Equal("PPO", indicator.LinesSeries[0].Name);
Assert.Equal("Signal", indicator.LinesSeries[1].Name);
Assert.Equal("Histogram", indicator.LinesSeries[2].Name);
}
[Fact]
public void PpoIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new PpoIndicator
{
FastPeriod = 2,
SlowPeriod = 5,
SignalPeriod = 2,
};
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 105, 95, 100 + i);
}
var args = new UpdateArgs(UpdateReason.HistoricalBar);
for (int i = 0; i < 10; i++)
{
indicator.ProcessUpdate(args);
}
double ppo = indicator.LinesSeries[0].GetValue(0);
double signal = indicator.LinesSeries[1].GetValue(0);
double hist = indicator.LinesSeries[2].GetValue(0);
Assert.False(double.IsNaN(ppo));
Assert.False(double.IsNaN(signal));
Assert.False(double.IsNaN(hist));
}
[Fact]
public void PpoIndicator_MultipleUpdates_ProducesFiniteSequence()
{
var indicator = new PpoIndicator
{
FastPeriod = 3,
SlowPeriod = 7,
SignalPeriod = 3,
};
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 30; 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(30, indicator.LinesSeries[0].Count);
for (int i = 0; i < 30; i++)
{
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(i)));
Assert.True(double.IsFinite(indicator.LinesSeries[1].GetValue(i)));
Assert.True(double.IsFinite(indicator.LinesSeries[2].GetValue(i)));
}
}
[Fact]
public void PpoIndicator_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 PpoIndicator { 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 PpoIndicator_ShowColdValues_False_SetsNaN()
{
var indicator = new PpoIndicator { 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 PpoIndicator_HistogramEqualsLineDifference()
{
var indicator = new PpoIndicator
{
FastPeriod = 3,
SlowPeriod = 7,
SignalPeriod = 3,
};
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 30; 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));
}
// Histogram should equal PPO line - Signal line
double ppo = indicator.LinesSeries[0].GetValue(0);
double signal = indicator.LinesSeries[1].GetValue(0);
double hist = indicator.LinesSeries[2].GetValue(0);
Assert.Equal(ppo - signal, hist, 10);
}
}
+78
View File
@@ -0,0 +1,78 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
/// <summary>
/// PPO (Percentage Price Oscillator) Quantower indicator.
/// Measures the percentage difference between fast and slow EMAs.
/// Formula: PPO = 100 × (FastEMA - SlowEMA) / SlowEMA
/// </summary>
[SkipLocalsInit]
public sealed class PpoIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Fast Period", sortIndex: 1, 1, 2000, 1, 0)]
public int FastPeriod { get; set; } = 12;
[InputParameter("Slow Period", sortIndex: 2, 1, 2000, 1, 0)]
public int SlowPeriod { get; set; } = 26;
[InputParameter("Signal Period", sortIndex: 3, 1, 2000, 1, 0)]
public int SignalPeriod { get; set; } = 9;
[IndicatorExtensions.DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Ppo _ppo = null!;
private readonly LineSeries _ppoSeries;
private readonly LineSeries _signalSeries;
private readonly LineSeries _histSeries;
private string _sourceName = null!;
private Func<IHistoryItem, double> _priceSelector = null!;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"PPO({FastPeriod},{SlowPeriod},{SignalPeriod}):{_sourceName}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/momentum/ppo/Ppo.Quantower.cs";
public PpoIndicator()
{
OnBackGround = true;
SeparateWindow = true;
_sourceName = Source.ToString();
Name = "PPO - Percentage Price Oscillator";
Description = "Percentage difference between fast and slow EMAs";
_ppoSeries = new LineSeries(name: "PPO", color: Color.Blue, width: 2, style: LineStyle.Solid);
_signalSeries = new LineSeries(name: "Signal", color: Color.Red, width: 2, style: LineStyle.Solid);
_histSeries = new LineSeries(name: "Histogram", color: Color.Green, width: 2, style: LineStyle.Solid);
AddLineSeries(_ppoSeries);
AddLineSeries(_signalSeries);
AddLineSeries(_histSeries);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_ppo = new Ppo(FastPeriod, SlowPeriod, SignalPeriod);
_sourceName = Source.ToString();
_priceSelector = Source.GetPriceSelector();
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
TValue result = _ppo.Update(new TValue(this.GetInputBar(args).Time, _priceSelector(HistoricalData[Count - 1, SeekOriginHistory.Begin])), args.IsNewBar());
_ppoSeries.SetValue(result.Value, _ppo.IsHot, ShowColdValues);
_signalSeries.SetValue(_ppo.Signal.Value, _ppo.IsHot, ShowColdValues);
_histSeries.SetValue(_ppo.Histogram.Value, _ppo.IsHot, ShowColdValues);
}
}
+476
View File
@@ -0,0 +1,476 @@
using Xunit;
namespace QuanTAlib.Tests;
public class PpoTests
{
private readonly TSeries _gbm;
private const int TestFastPeriod = 5;
private const int TestSlowPeriod = 10;
private const int TestSignalPeriod = 3;
private const int DataPoints = 100;
public PpoTests()
{
var gbm = new GBM(startPrice: 100, mu: 0.0, sigma: 0.5, seed: 42);
var bars = gbm.Fetch(DataPoints, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
_gbm = bars.Close;
}
#region Constructor Tests
[Fact]
public void Constructor_WithValidPeriods_SetsProperties()
{
var ppo = new Ppo(TestFastPeriod, TestSlowPeriod, TestSignalPeriod);
Assert.Equal($"Ppo({TestFastPeriod},{TestSlowPeriod},{TestSignalPeriod})", ppo.Name);
Assert.Equal(TestSlowPeriod + TestSignalPeriod, ppo.WarmupPeriod);
}
[Fact]
public void Constructor_DefaultParams_UsesStandardValues()
{
var ppo = new Ppo();
Assert.Equal("Ppo(12,26,9)", ppo.Name);
Assert.Equal(35, ppo.WarmupPeriod);
}
[Fact]
public void Constructor_WithZeroFastPeriod_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Ppo(0, 10, 3));
Assert.Equal("fastPeriod", ex.ParamName);
}
[Fact]
public void Constructor_WithZeroSlowPeriod_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Ppo(5, 0, 3));
Assert.Equal("slowPeriod", ex.ParamName);
}
[Fact]
public void Constructor_WithZeroSignalPeriod_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Ppo(5, 10, 0));
Assert.Equal("signalPeriod", ex.ParamName);
}
[Fact]
public void Constructor_FastNotLessThanSlow_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Ppo(10, 10, 3));
Assert.Equal("fastPeriod", ex.ParamName);
}
[Fact]
public void Constructor_FastGreaterThanSlow_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Ppo(15, 10, 3));
Assert.Equal("fastPeriod", ex.ParamName);
}
[Fact]
public void Constructor_WithSource_SubscribesToEvents()
{
var source = new TSeries(DataPoints);
var ppo = new Ppo(source, TestFastPeriod, TestSlowPeriod, TestSignalPeriod);
Assert.NotNull(ppo);
}
#endregion
#region Basic Calculation Tests
[Fact]
public void Update_FirstValue_ReturnsFinite()
{
var ppo = new Ppo(TestFastPeriod, TestSlowPeriod, TestSignalPeriod);
var tv = ppo.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.True(double.IsFinite(tv.Value));
}
[Fact]
public void Update_ConstantInput_ConvergesToZero()
{
var ppo = new Ppo(TestFastPeriod, TestSlowPeriod, TestSignalPeriod);
for (int i = 0; i < 80; i++)
{
ppo.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0), true);
}
// Constant price → FastEMA = SlowEMA → PPO = 0
Assert.True(Math.Abs(ppo.Last.Value) < 1e-6,
$"PPO with constant input should converge to 0, got {ppo.Last.Value}");
}
[Fact]
public void Signal_IsAccessible()
{
var ppo = new Ppo(TestFastPeriod, TestSlowPeriod, TestSignalPeriod);
for (int i = 0; i < 20; i++)
{
ppo.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i), true);
}
Assert.True(double.IsFinite(ppo.Signal.Value));
}
[Fact]
public void Histogram_IsAccessible()
{
var ppo = new Ppo(TestFastPeriod, TestSlowPeriod, TestSignalPeriod);
for (int i = 0; i < 20; i++)
{
ppo.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i), true);
}
Assert.True(double.IsFinite(ppo.Histogram.Value));
}
[Fact]
public void Histogram_EqualsPpoMinusSignal()
{
var ppo = new Ppo(TestFastPeriod, TestSlowPeriod, TestSignalPeriod);
for (int i = 0; i < 30; i++)
{
ppo.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i * 0.5), true);
}
Assert.Equal(ppo.Last.Value - ppo.Signal.Value, ppo.Histogram.Value, 10);
}
[Fact]
public void Update_RisingPrices_ReturnsPositive()
{
var ppo = new Ppo(TestFastPeriod, TestSlowPeriod, TestSignalPeriod);
for (int i = 0; i < 40; i++)
{
ppo.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i * 2.0), true);
}
Assert.True(ppo.Last.Value > 0,
$"PPO should be positive with rising prices, got {ppo.Last.Value}");
}
[Fact]
public void Update_FallingPrices_ReturnsNegative()
{
var ppo = new Ppo(TestFastPeriod, TestSlowPeriod, TestSignalPeriod);
for (int i = 0; i < 40; i++)
{
ppo.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 300.0 - i * 2.0), true);
}
Assert.True(ppo.Last.Value < 0,
$"PPO should be negative with falling prices, got {ppo.Last.Value}");
}
[Fact]
public void Last_IsAccessible()
{
var ppo = new Ppo(TestFastPeriod, TestSlowPeriod, TestSignalPeriod);
ppo.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.True(double.IsFinite(ppo.Last.Value));
}
[Fact]
public void IsHot_ReturnsFalseDuringWarmup()
{
var ppo = new Ppo(TestFastPeriod, TestSlowPeriod, TestSignalPeriod);
// It needs at least slow period bars before fast & slow EMAs are both hot
for (int i = 0; i < TestSlowPeriod; i++)
{
ppo.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
Assert.False(ppo.IsHot, $"Should not be hot at bar {i}");
}
}
[Fact]
public void IsHot_ReturnsTrueAfterWarmup()
{
var ppo = new Ppo(TestFastPeriod, TestSlowPeriod, TestSignalPeriod);
for (int i = 0; i < TestSlowPeriod + TestSignalPeriod + 5; i++)
{
ppo.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
}
Assert.True(ppo.IsHot);
}
#endregion
#region State Management Tests
[Fact]
public void Update_WithIsNewTrue_AdvancesState()
{
var ppo = new Ppo(TestFastPeriod, TestSlowPeriod, TestSignalPeriod);
var time = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
ppo.Update(new TValue(time.AddSeconds(i), 100.0 + i), true);
}
Assert.NotEqual(default, ppo.Last);
}
[Fact]
public void Update_WithIsNewFalse_RollsBackState()
{
var ppo = new Ppo(TestFastPeriod, TestSlowPeriod, TestSignalPeriod);
var time = DateTime.UtcNow;
for (int i = 0; i < 25; i++)
{
ppo.Update(new TValue(time.AddSeconds(i), 100.0 + i * 0.5), true);
}
var baseline = ppo.Update(new TValue(time.AddSeconds(25), 120.0), true);
var corrected = ppo.Update(new TValue(time.AddSeconds(25), 115.0), false);
Assert.NotEqual(baseline.Value, corrected.Value);
}
[Fact]
public void Update_IterativeCorrections_RestoresPreviousState()
{
var ppo = new Ppo(TestFastPeriod, TestSlowPeriod, TestSignalPeriod);
var time = DateTime.UtcNow;
for (int i = 0; i < 25; i++)
{
ppo.Update(new TValue(time.AddSeconds(i), 100.0 + i * 0.5), true);
}
var baseline = ppo.Update(new TValue(time.AddSeconds(25), 120.0), true);
ppo.Update(new TValue(time.AddSeconds(25), 130.0), false);
ppo.Update(new TValue(time.AddSeconds(25), 110.0), false);
var restored = ppo.Update(new TValue(time.AddSeconds(25), 120.0), false);
Assert.Equal(baseline.Value, restored.Value, 10);
}
[Fact]
public void Reset_ClearsState()
{
var ppo = new Ppo(TestFastPeriod, TestSlowPeriod, TestSignalPeriod);
for (int i = 0; i < 30; i++)
{
ppo.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
}
ppo.Reset();
Assert.Equal(default, ppo.Last);
Assert.Equal(default, ppo.Signal);
Assert.Equal(default, ppo.Histogram);
Assert.False(ppo.IsHot);
}
#endregion
#region Robustness Tests
[Fact]
public void Update_WithNaN_UsesLastValidValue()
{
var ppo = new Ppo(TestFastPeriod, TestSlowPeriod, TestSignalPeriod);
var time = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
ppo.Update(new TValue(time.AddSeconds(i), 100.0 + i), true);
}
var afterNaN = ppo.Update(new TValue(time.AddSeconds(20), double.NaN), true);
Assert.True(double.IsFinite(afterNaN.Value));
}
[Fact]
public void Update_WithInfinity_UsesLastValidValue()
{
var ppo = new Ppo(TestFastPeriod, TestSlowPeriod, TestSignalPeriod);
var time = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
ppo.Update(new TValue(time.AddSeconds(i), 100.0 + i), true);
}
var afterInf = ppo.Update(new TValue(time.AddSeconds(20), double.PositiveInfinity), true);
Assert.True(double.IsFinite(afterInf.Value));
}
[Fact]
public void Update_BatchNaN_HandlesSafely()
{
var ppo = new Ppo(TestFastPeriod, TestSlowPeriod, TestSignalPeriod);
var time = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
var value = i % 5 == 0 ? double.NaN : 100.0 + i;
var tv = ppo.Update(new TValue(time.AddSeconds(i), value), true);
Assert.True(double.IsFinite(tv.Value));
}
}
#endregion
#region Consistency Tests
[Fact]
public void BatchTSeries_And_Streaming_ProduceSameResults()
{
// Mode 1: Batch via TSeries
var batchResult = Ppo.Batch(_gbm, TestFastPeriod, TestSlowPeriod, TestSignalPeriod);
// Mode 2: Streaming
var streamingPpo = new Ppo(TestFastPeriod, TestSlowPeriod, TestSignalPeriod);
var streamingResult = new TSeries(DataPoints);
for (int i = 0; i < _gbm.Count; i++)
{
var tv = streamingPpo.Update(new TValue(_gbm[i].Time, _gbm[i].Value), true);
streamingResult.Add(tv, true);
}
// Compare last 50 values (post-warmup)
int start = Math.Max(0, DataPoints - 50);
for (int i = start; i < DataPoints; i++)
{
Assert.Equal(batchResult[i].Value, streamingResult[i].Value, 10);
}
}
[Fact]
public void SpanBatch_ProducesFiniteResults()
{
Span<double> spanOutput = stackalloc double[DataPoints];
Ppo.Batch(_gbm.Values, spanOutput, TestFastPeriod, TestSlowPeriod);
// Last value should be finite
Assert.True(double.IsFinite(spanOutput[DataPoints - 1]));
}
#endregion
#region Span API Tests
[Fact]
public void Calculate_Span_ValidatesMismatchedLengths()
{
var ex = Assert.Throws<ArgumentException>(() =>
{
ReadOnlySpan<double> source = stackalloc double[] { 1, 2, 3, 4, 5 };
Span<double> output = stackalloc double[3]; // different length
Ppo.Batch(source, output, TestFastPeriod, TestSlowPeriod);
});
Assert.Equal("destination", ex.ParamName);
}
[Fact]
public void Calculate_Span_ValidatesPeriod()
{
var ex = Assert.Throws<ArgumentException>(() =>
{
ReadOnlySpan<double> source = stackalloc double[] { 1, 2, 3, 4, 5 };
Span<double> output = stackalloc double[5];
Ppo.Batch(source, output, 0, TestSlowPeriod);
});
Assert.Contains("period", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Calculate_Span_LargeData_NoStackOverflow()
{
int largeSize = 10000;
double[] source = new double[largeSize];
double[] output = new double[largeSize];
for (int i = 0; i < largeSize; i++)
{
source[i] = 100.0 + i * 0.1;
}
Ppo.Batch(source, output, TestFastPeriod, TestSlowPeriod);
Assert.Equal(largeSize, output.Length);
Assert.True(double.IsFinite(output[^1]));
}
#endregion
#region Chainability Tests
[Fact]
public void Pub_FiresOnUpdate()
{
var ppo = new Ppo(TestFastPeriod, TestSlowPeriod, TestSignalPeriod);
bool eventFired = false;
ppo.Pub += (object? _, in TValueEventArgs e) => eventFired = true;
ppo.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.True(eventFired);
}
[Fact]
public void EventBasedChaining_Works()
{
var source = new TSeries(10);
var ppo = new Ppo(source, 2, 5, 3);
var results = new List<double>();
ppo.Pub += (object? _, in TValueEventArgs e) => results.Add(e.Value.Value);
for (int i = 0; i < 20; i++)
{
source.Add(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i), true);
}
Assert.Equal(20, results.Count);
}
#endregion
#region Calculate Method Tests
[Fact]
public void Calculate_ReturnsTupleWithResultsAndIndicator()
{
var (results, indicator) = Ppo.Calculate(_gbm, TestFastPeriod, TestSlowPeriod, TestSignalPeriod);
Assert.Equal(DataPoints, results.Count);
Assert.NotNull(indicator);
Assert.True(indicator.IsHot);
}
[Fact]
public void Prime_InitializesState()
{
var ppo = new Ppo(TestFastPeriod, TestSlowPeriod, TestSignalPeriod);
double[] primeData = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109,
110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120];
ppo.Prime(primeData);
Assert.NotEqual(default, ppo.Last);
Assert.True(ppo.IsHot);
}
[Fact]
public void Prime_SameAsSequentialUpdates()
{
var ppo1 = new Ppo(TestFastPeriod, TestSlowPeriod, TestSignalPeriod);
var ppo2 = new Ppo(TestFastPeriod, TestSlowPeriod, TestSignalPeriod);
double[] data = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109,
110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120];
ppo1.Prime(data);
foreach (var value in data)
{
ppo2.Update(new TValue(DateTime.MinValue, value));
}
Assert.Equal(ppo1.Last.Value, ppo2.Last.Value, 10);
}
#endregion
}
+316
View File
@@ -0,0 +1,316 @@
using OoplesFinance.StockIndicators;
using OoplesFinance.StockIndicators.Models;
using Xunit;
using Xunit.Abstractions;
using TALib;
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for PPO (Percentage Price Oscillator) against external libraries.
/// Tulip has a 'ppo' indicator.
/// TA-Lib has PPO function.
/// Ooples has CalculatePercentagePriceOscillator().
/// Skender does not have a PPO indicator.
/// </summary>
public sealed class PpoValidationTests(ITestOutputHelper output) : IDisposable
{
private readonly ValidationTestData _testData = new();
private readonly ITestOutputHelper _output = output;
private bool _disposed;
public void Dispose()
{
Dispose(disposing: true);
}
private void Dispose(bool disposing)
{
if (_disposed) { return; }
_disposed = true;
if (disposing) { _testData?.Dispose(); }
}
#region Tulip PPO Validation
[Fact]
public void Ppo_MatchesTulipPpo_Streaming()
{
// Tulip has hardcoded alpha overrides for 12/26, use different periods
const int fastPeriod = 10;
const int slowPeriod = 20;
const int signalPeriod = 9;
double[] tData = _testData.RawData.ToArray();
// Calculate QuanTAlib PPO (streaming)
var ppo = new global::QuanTAlib.Ppo(fastPeriod, slowPeriod, signalPeriod);
var qPpo = new List<double>();
foreach (var item in _testData.Data)
{
ppo.Update(item);
qPpo.Add(ppo.Last.Value);
}
// Calculate Tulip PPO
var ppoIndicator = Tulip.Indicators.ppo;
double[][] inputs = [tData];
double[] options = [fastPeriod, slowPeriod];
int lookback = ppoIndicator.Start(options);
double[][] outputs = [new double[tData.Length - lookback]];
ppoIndicator.Run(inputs, options, outputs);
var tPpo = outputs[0];
// Compare last 100 records
ValidationHelper.VerifyData(qPpo, tPpo, lookback);
_output.WriteLine("PPO Streaming validated successfully against Tulip");
}
[Theory]
[InlineData(5, 15)]
[InlineData(8, 21)]
[InlineData(10, 20)]
[InlineData(15, 30)]
public void Ppo_MatchesTulipPpo_DifferentPeriods(int fastPeriod, int slowPeriod)
{
double[] tData = _testData.RawData.ToArray();
// QuanTAlib PPO
var ppo = new global::QuanTAlib.Ppo(fastPeriod, slowPeriod, 9);
var qPpo = new List<double>();
foreach (var item in _testData.Data)
{
ppo.Update(item);
qPpo.Add(ppo.Last.Value);
}
// Tulip PPO
var ppoIndicator = Tulip.Indicators.ppo;
double[][] inputs = [tData];
double[] options = [fastPeriod, slowPeriod];
int lookback = ppoIndicator.Start(options);
double[][] outputs = [new double[tData.Length - lookback]];
ppoIndicator.Run(inputs, options, outputs);
var tPpo = outputs[0];
ValidationHelper.VerifyData(qPpo, tPpo, lookback);
}
#endregion
#region TA-Lib PPO Validation
[Fact]
public void Ppo_MatchesTalib_Streaming()
{
const int fastPeriod = 12;
const int slowPeriod = 26;
double[] tData = _testData.RawData.ToArray();
double[] outPpo = new double[tData.Length];
// QuanTAlib PPO (streaming)
var ppo = new global::QuanTAlib.Ppo(fastPeriod, slowPeriod, 9);
var qPpo = new List<double>();
foreach (var item in _testData.Data)
{
ppo.Update(item);
qPpo.Add(ppo.Last.Value);
}
// TA-Lib PPO (must specify MAType.Ema — default is SMA which differs from our EMA-based PPO)
var retCode = TALib.Functions.Ppo<double>(tData, 0..^0, outPpo, out var outRange, fastPeriod, slowPeriod, Core.MAType.Ema);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = TALib.Functions.PpoLookback(fastPeriod, slowPeriod, Core.MAType.Ema);
// Compare
ValidationHelper.VerifyData(qPpo, outPpo, outRange, lookback);
_output.WriteLine("PPO Streaming validated successfully against TA-Lib");
}
#endregion
#region Ooples Validation
[Fact]
public void Ppo_MatchesOoples_Batch()
{
const int fastPeriod = 12;
const int slowPeriod = 26;
const int signalPeriod = 9;
var ooplesData = _testData.SkenderQuotes.Select(q => new TickerData
{
Date = q.Date,
Open = (double)q.Open,
High = (double)q.High,
Low = (double)q.Low,
Close = (double)q.Close,
Volume = (double)q.Volume
}).ToList();
// QuanTAlib PPO
var ppo = new global::QuanTAlib.Ppo(fastPeriod, slowPeriod, signalPeriod);
var qPpo = new List<double>();
foreach (var item in _testData.Data)
{
ppo.Update(item);
qPpo.Add(ppo.Last.Value);
}
// Ooples PPO
var stockData = new StockData(ooplesData);
var oResult = stockData.CalculatePercentagePriceOscillator(
fastLength: fastPeriod, slowLength: slowPeriod, signalLength: signalPeriod);
var oValues = oResult.OutputValues.Values.First();
int count = qPpo.Count;
int warmup = slowPeriod + signalPeriod;
int start = Math.Max(warmup, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
Assert.True(
Math.Abs(qPpo[i] - oValues[i]) <= ValidationHelper.OoplesTolerance,
$"Mismatch at index {i}: QuanTAlib={qPpo[i]:G17}, Ooples={oValues[i]:G17}");
}
_output.WriteLine("PPO Batch validated successfully against Ooples");
}
#endregion
#region Self-Consistency
[Fact]
public void Ppo_BatchAndStreaming_AreIdentical()
{
const int fastPeriod = 12;
const int slowPeriod = 26;
const int signalPeriod = 9;
// Batch
var batchResult = global::QuanTAlib.Ppo.Batch(_testData.Data, fastPeriod, slowPeriod, signalPeriod);
// Streaming
var ppo = new global::QuanTAlib.Ppo(fastPeriod, slowPeriod, signalPeriod);
var streamingResults = new List<double>();
foreach (var item in _testData.Data)
{
ppo.Update(item);
streamingResults.Add(ppo.Last.Value);
}
// They must match exactly
for (int i = 0; i < _testData.Data.Count; i++)
{
Assert.Equal(batchResult[i].Value, streamingResults[i], 1e-10);
}
}
[Fact]
public void Ppo_HistogramEqualsLineMinusSignal()
{
const int fastPeriod = 12;
const int slowPeriod = 26;
const int signalPeriod = 9;
var ppo = new global::QuanTAlib.Ppo(fastPeriod, slowPeriod, signalPeriod);
foreach (var item in _testData.Data)
{
ppo.Update(item);
double line = ppo.Last.Value;
double signal = ppo.Signal.Value;
double hist = ppo.Histogram.Value;
Assert.Equal(line - signal, hist, 1e-10);
}
}
[Fact]
public void Ppo_ConstantInput_ConvergesToZero()
{
var ppo = new global::QuanTAlib.Ppo(12, 26, 9);
for (int i = 0; i < 200; i++)
{
ppo.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0), true);
}
Assert.True(Math.Abs(ppo.Last.Value) < 1e-6,
$"PPO should converge to 0 for constant input, got {ppo.Last.Value}");
Assert.True(Math.Abs(ppo.Signal.Value) < 1e-6,
$"Signal should converge to 0 for constant input, got {ppo.Signal.Value}");
Assert.True(Math.Abs(ppo.Histogram.Value) < 1e-6,
$"Histogram should converge to 0 for constant input, got {ppo.Histogram.Value}");
}
#endregion
#region Edge Cases
[Fact]
public void Ppo_AllOutputsFiniteAfterWarmup()
{
var ppo = new global::QuanTAlib.Ppo(12, 26, 9);
foreach (var item in _testData.Data)
{
ppo.Update(item);
Assert.True(double.IsFinite(ppo.Last.Value),
$"PPO output should be finite, got {ppo.Last.Value}");
Assert.True(double.IsFinite(ppo.Signal.Value),
$"Signal output should be finite, got {ppo.Signal.Value}");
Assert.True(double.IsFinite(ppo.Histogram.Value),
$"Histogram output should be finite, got {ppo.Histogram.Value}");
}
}
[Fact]
public void Ppo_ResetProducesIdenticalResults()
{
const int fastPeriod = 12;
const int slowPeriod = 26;
const int signalPeriod = 9;
var ppo = new global::QuanTAlib.Ppo(fastPeriod, slowPeriod, signalPeriod);
// First run
foreach (var item in _testData.Data)
{
ppo.Update(item);
}
var firstPpo = ppo.Last.Value;
var firstSignal = ppo.Signal.Value;
var firstHist = ppo.Histogram.Value;
ppo.Reset();
// Second run
foreach (var item in _testData.Data)
{
ppo.Update(item);
}
Assert.Equal(firstPpo, ppo.Last.Value, 1e-10);
Assert.Equal(firstSignal, ppo.Signal.Value, 1e-10);
Assert.Equal(firstHist, ppo.Histogram.Value, 1e-10);
}
#endregion
}
+280
View File
@@ -0,0 +1,280 @@
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// Computes the Percentage Price Oscillator (PPO), which measures the percentage difference
/// between a fast and slow exponential moving average.
/// </summary>
/// <remarks>
/// PPO Formula:
/// <c>PPO = 100 × (FastEMA - SlowEMA) / SlowEMA</c>.
///
/// PPO is similar to MACD but normalized as a percentage, enabling comparison across
/// different price levels. Positive values indicate the fast EMA is above the slow EMA.
/// This implementation uses compensated EMAs for warmup accuracy and FMA for performance.
/// Non-finite inputs (NaN/±Inf) are sanitized by substituting the last finite value observed.
///
/// For the authoritative algorithm reference, full rationale, and behavioral contracts, see the
/// companion files in the same directory.
/// </remarks>
/// <seealso href="ppo.pine">Reference Pine Script implementation</seealso>
[SkipLocalsInit]
public sealed class Ppo : AbstractBase
{
private const int DefaultFastPeriod = 12;
private const int DefaultSlowPeriod = 26;
private const int DefaultSignalPeriod = 9;
private readonly Ema _fastEma;
private readonly Ema _slowEma;
private readonly Ema _signalEma;
private record struct State(double LastValid);
private State _state, _p_state;
private ITValuePublisher? _source;
private bool _disposed;
/// <summary>
/// Gets the most recent signal line value (EMA of PPO line).
/// </summary>
public TValue Signal { get; private set; }
/// <summary>
/// Gets the most recent histogram value (PPO - Signal).
/// </summary>
public TValue Histogram { get; private set; }
/// <summary>
/// True when both fast and slow EMAs have warmed up.
/// </summary>
public override bool IsHot => _fastEma.IsHot && _slowEma.IsHot;
/// <summary>
/// Initializes a new PPO indicator.
/// </summary>
/// <param name="fastPeriod">Fast EMA period (must be >= 1)</param>
/// <param name="slowPeriod">Slow EMA period (must be >= 1 and > fastPeriod)</param>
/// <param name="signalPeriod">Signal line EMA period (must be >= 1)</param>
public Ppo(int fastPeriod = DefaultFastPeriod, int slowPeriod = DefaultSlowPeriod, int signalPeriod = DefaultSignalPeriod)
{
if (fastPeriod < 1)
{
throw new ArgumentException("Fast period must be >= 1", nameof(fastPeriod));
}
if (slowPeriod < 1)
{
throw new ArgumentException("Slow period must be >= 1", nameof(slowPeriod));
}
if (signalPeriod < 1)
{
throw new ArgumentException("Signal period must be >= 1", nameof(signalPeriod));
}
if (fastPeriod >= slowPeriod)
{
throw new ArgumentException("Fast period must be less than slow period", nameof(fastPeriod));
}
_fastEma = new Ema(fastPeriod);
_slowEma = new Ema(slowPeriod);
_signalEma = new Ema(signalPeriod);
Name = $"Ppo({fastPeriod},{slowPeriod},{signalPeriod})";
WarmupPeriod = slowPeriod + signalPeriod;
}
/// <summary>
/// Initializes a new PPO indicator with source for event-based chaining.
/// </summary>
public Ppo(ITValuePublisher source, int fastPeriod = DefaultFastPeriod, int slowPeriod = DefaultSlowPeriod, int signalPeriod = DefaultSignalPeriod)
: this(fastPeriod, slowPeriod, signalPeriod)
{
_source = source;
_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 = double.IsFinite(input.Value) ? input.Value : _state.LastValid;
_state = new State(value);
var safeInput = new TValue(input.Time, value);
var fast = _fastEma.Update(safeInput, isNew);
var slow = _slowEma.Update(safeInput, isNew);
// PPO = 100 * (FastEMA - SlowEMA) / SlowEMA
double ppoValue = slow.Value != 0.0
? 100.0 * (fast.Value - slow.Value) / slow.Value
: 0.0;
var ppoTValue = new TValue(input.Time, ppoValue);
var signal = _signalEma.Update(ppoTValue, isNew);
double histValue = ppoValue - signal.Value;
Last = ppoTValue;
Signal = signal;
Histogram = new TValue(input.Time, histValue);
PubEvent(Last, isNew);
return Last;
}
public override TSeries Update(TSeries source)
{
if (source.Count == 0)
{
return [];
}
int len = source.Count;
var t = new List<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
Reset();
for (int i = 0; i < len; i++)
{
Update(new TValue(new DateTime(source.Times[i], DateTimeKind.Utc), source.Values[i]), true);
tSpan[i] = source.Times[i];
vSpan[i] = Last.Value;
}
_p_state = _state;
return new TSeries(t, v);
}
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 fastPeriod = DefaultFastPeriod, int slowPeriod = DefaultSlowPeriod, int signalPeriod = DefaultSignalPeriod)
{
var indicator = new Ppo(fastPeriod, slowPeriod, signalPeriod);
return indicator.Update(source);
}
/// <summary>
/// Calculates PPO line over a span of values.
/// Zero-allocation method for maximum performance.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan<double> source, Span<double> destination, int fastPeriod = DefaultFastPeriod, int slowPeriod = DefaultSlowPeriod)
{
if (source.Length != destination.Length)
{
throw new ArgumentException("Source and destination must be same length", nameof(destination));
}
if (fastPeriod < 1)
{
throw new ArgumentException("Fast period must be >= 1", nameof(fastPeriod));
}
if (slowPeriod < 1)
{
throw new ArgumentException("Slow period must be >= 1", nameof(slowPeriod));
}
if (fastPeriod >= slowPeriod)
{
throw new ArgumentException("Fast period must be less than slow period", nameof(fastPeriod));
}
int len = source.Length;
double[] fastBuffer = ArrayPool<double>.Shared.Rent(len);
double[] slowBuffer = ArrayPool<double>.Shared.Rent(len);
try
{
Span<double> fastSpan = fastBuffer.AsSpan(0, len);
Span<double> slowSpan = slowBuffer.AsSpan(0, len);
Ema.Batch(source, fastSpan, fastPeriod);
Ema.Batch(source, slowSpan, slowPeriod);
for (int i = 0; i < len; i++)
{
destination[i] = slowSpan[i] != 0.0
? 100.0 * (fastSpan[i] - slowSpan[i]) / slowSpan[i]
: 0.0;
}
}
finally
{
ArrayPool<double>.Shared.Return(fastBuffer);
ArrayPool<double>.Shared.Return(slowBuffer);
}
}
public static (TSeries Results, Ppo Indicator) Calculate(TSeries source, int fastPeriod = DefaultFastPeriod, int slowPeriod = DefaultSlowPeriod, int signalPeriod = DefaultSignalPeriod)
{
var indicator = new Ppo(fastPeriod, slowPeriod, signalPeriod);
TSeries results = indicator.Update(source);
return (results, indicator);
}
public override void Reset()
{
_fastEma.Reset();
_slowEma.Reset();
_signalEma.Reset();
_state = default;
_p_state = default;
Last = default;
Signal = default;
Histogram = default;
}
protected override void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
if (_source != null)
{
_source.Pub -= HandleUpdate;
_source = null;
}
_fastEma.Dispose();
_slowEma.Dispose();
_signalEma.Dispose();
}
_disposed = true;
}
base.Dispose(disposing);
}
}