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