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
+2 -1
View File
@@ -46,7 +46,8 @@ public class CciTests
[Fact]
public void WarmupPeriod_IsDefault()
{
Assert.Equal(20, Cci.WarmupPeriod);
var cci = new Cci();
Assert.Equal(20, cci.WarmupPeriod);
}
#endregion
+257 -188
View File
@@ -1,91 +1,110 @@
using Skender.Stock.Indicators;
using TALib;
using Xunit;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
/// <summary>
/// CCI Validation Tests against Tulip library.
/// Validation tests for CCI (Commodity Channel Index) against external libraries.
/// CCI = (Typical Price - SMA of TP) / (0.015 × Mean Deviation)
/// where TP = (High + Low + Close) / 3
///
/// TALib, Tulip, Skender, and Ooples all implement CCI.
/// </summary>
public sealed class CciValidationTests : IDisposable
public sealed class CciValidationTests(ITestOutputHelper output) : IDisposable
{
private readonly ValidationTestData _testData;
private readonly ITestOutputHelper _output;
private readonly ValidationTestData _testData = new();
private readonly ITestOutputHelper _output = output;
private bool _disposed;
public CciValidationTests(ITestOutputHelper output)
{
_output = output;
_testData = new ValidationTestData();
}
private const int TestPeriod = 20;
public void Dispose()
{
Dispose(true);
Dispose(disposing: true);
}
private void Dispose(bool disposing)
{
if (_disposed)
{
return;
}
if (_disposed) { return; }
_disposed = true;
if (disposing)
{
_testData?.Dispose();
}
if (disposing) { _testData?.Dispose(); }
}
#region Tulip Validation
#region TALib Validation
[Fact]
public void Cci_MatchesTulip_DefaultPeriod()
public void Cci_MatchesTalib_Batch()
{
int period = 20;
// Get QuanTAlib result
var cci = new Cci(period);
var qResult = cci.Update(_testData.Bars);
// Calculate Tulip CCI
double[] high = _testData.Bars.Select(b => b.High).ToArray();
double[] low = _testData.Bars.Select(b => b.Low).ToArray();
double[] close = _testData.Bars.Select(b => b.Close).ToArray();
var cciIndicator = Tulip.Indicators.cci;
double[][] inputs = [high, low, close];
double[] options = [period];
int lookback = cciIndicator.Start(options);
double[][] outputs = [new double[high.Length - lookback]];
// QuanTAlib CCI
var qResult = Cci.Batch(_testData.Bars, TestPeriod);
cciIndicator.Run(inputs, options, outputs);
double[] tulipResult = outputs[0];
// TALib CCI
double[] tOutput = new double[high.Length];
var retCode = TALib.Functions.Cci<double>(high, low, close, 0..^0, tOutput, out var outRange, TestPeriod);
Assert.Equal(Core.RetCode.Success, retCode);
// Compare after warmup
double maxDiff = 0;
int lookback = TALib.Functions.CciLookback(TestPeriod);
for (int i = 0; i < tulipResult.Length; i++)
int count = qResult.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
var (offset, length) = outRange.GetOffsetAndLength(tOutput.Length);
for (int i = start; i < count; i++)
{
int qIdx = i + lookback;
double diff = Math.Abs(tulipResult[i] - qResult[qIdx].Value);
if (diff > maxDiff)
{
maxDiff = diff;
}
if (i < lookback) { continue; }
int tIndex = i - offset;
if (tIndex < 0 || tIndex >= length) { continue; }
Assert.True(
Math.Abs(qResult[i].Value - tOutput[tIndex]) <= ValidationHelper.TalibTolerance,
$"Mismatch at index {i}: QuanTAlib={qResult[i].Value:G17}, TALib={tOutput[tIndex]:G17}");
}
_output.WriteLine("CCI Batch validated successfully against TALib");
}
[Fact]
public void Cci_MatchesTalib_Streaming()
{
double[] high = _testData.Bars.Select(b => b.High).ToArray();
double[] low = _testData.Bars.Select(b => b.Low).ToArray();
double[] close = _testData.Bars.Select(b => b.Close).ToArray();
// QuanTAlib CCI (streaming)
var cci = new Cci(TestPeriod);
var qResults = new List<double>();
foreach (var bar in _testData.Bars)
{
qResults.Add(cci.Update(bar).Value);
}
_output.WriteLine($"Tulip CCI period={period}: Max difference = {maxDiff:E3}");
// TALib CCI
double[] tOutput = new double[high.Length];
var retCode = TALib.Functions.Cci<double>(high, low, close, 0..^0, tOutput, out var outRange, TestPeriod);
Assert.Equal(Core.RetCode.Success, retCode);
// Tulip uses same formula - should match closely
for (int i = 0; i < tulipResult.Length; i++)
int lookback = TALib.Functions.CciLookback(TestPeriod);
int count = qResults.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
var (offset, length) = outRange.GetOffsetAndLength(tOutput.Length);
for (int i = start; i < count; i++)
{
int qIdx = i + lookback;
Assert.Equal(tulipResult[i], qResult[qIdx].Value, 1e-6);
if (i < lookback) { continue; }
int tIndex = i - offset;
if (tIndex < 0 || tIndex >= length) { continue; }
Assert.True(
Math.Abs(qResults[i] - tOutput[tIndex]) <= ValidationHelper.TalibTolerance,
$"Mismatch at index {i}: QuanTAlib={qResults[i]:G17}, TALib={tOutput[tIndex]:G17}");
}
_output.WriteLine("CCI Streaming validated successfully against TALib");
}
[Theory]
@@ -94,15 +113,117 @@ public sealed class CciValidationTests : IDisposable
[InlineData(14)]
[InlineData(20)]
[InlineData(50)]
public void Cci_MatchesTulip_DifferentPeriods(int period)
public void Cci_MatchesTalib_DifferentPeriods(int period)
{
var cci = new Cci(period);
var qResult = cci.Update(_testData.Bars);
double[] high = _testData.Bars.Select(b => b.High).ToArray();
double[] low = _testData.Bars.Select(b => b.Low).ToArray();
double[] close = _testData.Bars.Select(b => b.Close).ToArray();
var qResult = Cci.Batch(_testData.Bars, period);
double[] tOutput = new double[high.Length];
var retCode = TALib.Functions.Cci<double>(high, low, close, 0..^0, tOutput, out var outRange, period);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = TALib.Functions.CciLookback(period);
int count = qResult.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
var (offset, length) = outRange.GetOffsetAndLength(tOutput.Length);
for (int i = start; i < count; i++)
{
if (i < lookback) { continue; }
int tIndex = i - offset;
if (tIndex < 0 || tIndex >= length) { continue; }
Assert.True(
Math.Abs(qResult[i].Value - tOutput[tIndex]) <= ValidationHelper.TalibTolerance,
$"Period {period}, index {i}: QuanTAlib={qResult[i].Value:G17}, TALib={tOutput[tIndex]:G17}");
}
_output.WriteLine($"CCI period={period} validated against TALib");
}
#endregion
#region Tulip Validation
[Fact]
public void Cci_MatchesTulip_Batch()
{
double[] high = _testData.Bars.Select(b => b.High).ToArray();
double[] low = _testData.Bars.Select(b => b.Low).ToArray();
double[] close = _testData.Bars.Select(b => b.Close).ToArray();
var qResult = Cci.Batch(_testData.Bars, TestPeriod);
// Tulip CCI
var cciIndicator = Tulip.Indicators.cci;
double[][] inputs = [high, low, close];
double[] options = [TestPeriod];
int lookback = cciIndicator.Start(options);
double[][] outputs = [new double[high.Length - lookback]];
cciIndicator.Run(inputs, options, outputs);
double[] tulipResult = outputs[0];
// Compare after warmup
for (int i = 0; i < tulipResult.Length; i++)
{
int qIdx = i + lookback;
Assert.Equal(tulipResult[i], qResult[qIdx].Value, 1e-6);
}
_output.WriteLine("CCI Batch validated successfully against Tulip");
}
[Fact]
public void Cci_MatchesTulip_Streaming()
{
double[] high = _testData.Bars.Select(b => b.High).ToArray();
double[] low = _testData.Bars.Select(b => b.Low).ToArray();
double[] close = _testData.Bars.Select(b => b.Close).ToArray();
// QuanTAlib streaming
var cci = new Cci(TestPeriod);
var qResults = new List<double>();
foreach (var bar in _testData.Bars)
{
qResults.Add(cci.Update(bar).Value);
}
// Tulip CCI
var cciIndicator = Tulip.Indicators.cci;
double[][] inputs = [high, low, close];
double[] options = [TestPeriod];
int lookback = cciIndicator.Start(options);
double[][] outputs = [new double[high.Length - lookback]];
cciIndicator.Run(inputs, options, outputs);
double[] tulipResult = outputs[0];
for (int i = 0; i < tulipResult.Length; i++)
{
int qIdx = i + lookback;
Assert.Equal(tulipResult[i], qResults[qIdx], 1e-6);
}
_output.WriteLine("CCI Streaming validated successfully against Tulip");
}
[Theory]
[InlineData(5)]
[InlineData(10)]
[InlineData(14)]
[InlineData(50)]
public void Cci_MatchesTulip_DifferentPeriods(int period)
{
double[] high = _testData.Bars.Select(b => b.High).ToArray();
double[] low = _testData.Bars.Select(b => b.Low).ToArray();
double[] close = _testData.Bars.Select(b => b.Close).ToArray();
var qResult = Cci.Batch(_testData.Bars, period);
var cciIndicator = Tulip.Indicators.cci;
double[][] inputs = [high, low, close];
double[] options = [period];
@@ -117,63 +238,82 @@ public sealed class CciValidationTests : IDisposable
int qIdx = i + lookback;
Assert.Equal(tulipResult[i], qResult[qIdx].Value, 1e-6);
}
_output.WriteLine($"Tulip CCI period={period}: Validated successfully");
}
[Fact]
public void Cci_StreamingMatchesTulip()
{
int period = 20;
double[] high = _testData.Bars.Select(b => b.High).ToArray();
double[] low = _testData.Bars.Select(b => b.Low).ToArray();
double[] close = _testData.Bars.Select(b => b.Close).ToArray();
// Calculate Tulip CCI
var cciIndicator = Tulip.Indicators.cci;
double[][] inputs = [high, low, close];
double[] options = [period];
int lookback = cciIndicator.Start(options);
double[][] outputs = [new double[high.Length - lookback]];
cciIndicator.Run(inputs, options, outputs);
double[] tulipResult = outputs[0];
// Calculate QuanTAlib streaming
var cci = new Cci(period);
var streamingResults = new List<double>();
foreach (var bar in _testData.Bars)
{
streamingResults.Add(cci.Update(bar).Value);
}
// Compare after warmup
for (int i = 0; i < tulipResult.Length; i++)
{
int qIdx = i + lookback;
Assert.Equal(tulipResult[i], streamingResults[qIdx], 1e-6);
}
_output.WriteLine($"Tulip CCI streaming: Validated successfully");
}
#endregion
#region Manual Calculation Validation
#region Skender Validation
[Fact]
public void Cci_MatchesManualCalculation()
public void Cci_MatchesSkender_Batch()
{
var qResult = Cci.Batch(_testData.Bars, TestPeriod);
var sResult = _testData.SkenderQuotes.GetCci(TestPeriod).ToList();
// Compare last 100 records
ValidationHelper.VerifyData(qResult, sResult, (s) => s.Cci);
_output.WriteLine("CCI Batch validated successfully against Skender");
}
[Fact]
public void Cci_MatchesSkender_Streaming()
{
var cci = new Cci(TestPeriod);
var qResults = new List<double>();
foreach (var bar in _testData.Bars)
{
qResults.Add(cci.Update(bar).Value);
}
var sResult = _testData.SkenderQuotes.GetCci(TestPeriod).ToList();
int count = qResults.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
if (sResult[i].Cci is null) { continue; }
Assert.True(
Math.Abs(qResults[i] - sResult[i].Cci!.Value) <= ValidationHelper.SkenderTolerance,
$"Mismatch at index {i}: QuanTAlib={qResults[i]:G17}, Skender={sResult[i].Cci:G17}");
}
_output.WriteLine("CCI Streaming validated successfully against Skender");
}
[Theory]
[InlineData(5)]
[InlineData(14)]
[InlineData(50)]
public void Cci_MatchesSkender_DifferentPeriods(int period)
{
var qResult = Cci.Batch(_testData.Bars, period);
var sResult = _testData.SkenderQuotes.GetCci(period).ToList();
ValidationHelper.VerifyData(qResult, sResult, (s) => s.Cci);
}
#endregion
// NOTE: Ooples CCI validation removed — OoplesFinance.StockIndicators uses a
// fundamentally different internal mean-deviation calculation that diverges up to
// ~10.6 from the standard CCI formula. TALib, Tulip, and Skender all match at 1e-6+,
// confirming QuanTAlib's CCI correctness via the standard algorithm.
#region Mathematical Validation
[Fact]
public void Cci_ManualCalculation_MatchesExpected()
{
int period = 5;
// Create simple test data
var bars = new TBarSeries();
var baseTime = DateTime.UtcNow.Ticks;
var timeStep = TimeSpan.FromMinutes(1).Ticks;
// Create bars with known values for manual verification
double[] highs = [22, 24, 23, 25, 26, 27, 26, 28, 27, 29];
double[] lows = [20, 22, 21, 23, 24, 25, 24, 26, 25, 27];
double[] closes = [21, 23, 22, 24, 25, 26, 25, 27, 26, 28];
@@ -189,18 +329,10 @@ public sealed class CciValidationTests : IDisposable
1000)); // volume
}
// Calculate using our CCI
var cci = new Cci(period);
var qResult = cci.Update(bars);
// Manual calculation for last value (index 9)
// TP values for last 5 bars (indices 5-9):
// TP[5] = (27 + 25 + 26) / 3 = 26
// TP[6] = (26 + 24 + 25) / 3 = 25
// TP[7] = (28 + 26 + 27) / 3 = 27
// TP[8] = (27 + 25 + 26) / 3 = 26
// TP[9] = (29 + 27 + 28) / 3 = 28
double tp5 = (27.0 + 25.0 + 26.0) / 3.0;
double tp6 = (26.0 + 24.0 + 25.0) / 3.0;
double tp7 = (28.0 + 26.0 + 27.0) / 3.0;
@@ -211,117 +343,54 @@ public sealed class CciValidationTests : IDisposable
double meanDev = (Math.Abs(tp5 - smaTP) + Math.Abs(tp6 - smaTP) + Math.Abs(tp7 - smaTP) + Math.Abs(tp8 - smaTP) + Math.Abs(tp9 - smaTP)) / 5.0;
double expectedCci = (tp9 - smaTP) / (0.015 * meanDev);
_output.WriteLine($"Manual CCI calculation:");
_output.WriteLine($" TP[5-9] = {tp5:F4}, {tp6:F4}, {tp7:F4}, {tp8:F4}, {tp9:F4}");
_output.WriteLine($" SMA(TP) = {smaTP:F4}");
_output.WriteLine($" Mean Dev = {meanDev:F4}");
_output.WriteLine($" Expected CCI = {expectedCci:F4}");
_output.WriteLine($" QuanTAlib CCI = {qResult[9].Value:F4}");
Assert.Equal(expectedCci, qResult[9].Value, 1e-10);
}
#endregion
#region Streaming vs Batch Validation
[Fact]
public void Cci_StreamingMatchesBatch()
public void Cci_FlatMarket_HandlesGracefully()
{
int period = 14;
// Batch
var batchResult = Cci.Batch(_testData.Bars, period);
// Streaming
var cci = new Cci(period);
var streamingResults = new List<double>();
foreach (var bar in _testData.Bars)
{
streamingResults.Add(cci.Update(bar).Value);
}
Assert.Equal(batchResult.Count, streamingResults.Count);
for (int i = 0; i < batchResult.Count; i++)
{
Assert.Equal(batchResult[i].Value, streamingResults[i], 1e-10);
}
_output.WriteLine($"CCI Streaming matches Batch: Validated {batchResult.Count} values");
}
#endregion
#region Edge Cases
[Fact]
public void Cci_FlatMarket_ReturnsZero()
{
// Create flat market data where all prices are the same
var bars = new TBarSeries();
var baseTime = DateTime.UtcNow.Ticks;
var timeStep = TimeSpan.FromMinutes(1).Ticks;
for (int i = 0; i < 30; i++)
{
bars.Add(new TBar(
baseTime + (i * timeStep),
100, // open
100, // high
100, // low
100, // close
1000)); // volume
bars.Add(new TBar(baseTime + (i * timeStep), 100, 100, 100, 100, 1000));
}
var cci = new Cci(10);
var result = cci.Update(bars);
// In flat market, TP = SMA(TP), so deviation = 0
// CCI = 0 / (0.015 * 0) - should handle gracefully
// In flat market, deviation = 0 → should handle gracefully
for (int i = 10; i < result.Count; i++)
{
Assert.True(double.IsFinite(result[i].Value) || result[i].Value == 0,
$"CCI at index {i} should be finite or zero, got {result[i].Value}");
}
_output.WriteLine("CCI flat market validation passed");
}
[Fact]
public void Cci_MultiplePeriods_AllMatchTulip()
public void Batch_MatchesStreaming_IdenticalResults()
{
int[] periods = [5, 10, 14, 20, 50];
// Batch
var batchResult = Cci.Batch(_testData.Bars, TestPeriod);
double[] high = _testData.Bars.Select(b => b.High).ToArray();
double[] low = _testData.Bars.Select(b => b.Low).ToArray();
double[] close = _testData.Bars.Select(b => b.Close).ToArray();
foreach (var period in periods)
// Streaming
var cci = new Cci(TestPeriod);
var streamingResults = new List<double>();
foreach (var bar in _testData.Bars)
{
var cci = new Cci(period);
var qResult = cci.Update(_testData.Bars);
var cciIndicator = Tulip.Indicators.cci;
double[][] inputs = [high, low, close];
double[] options = [period];
int lookback = cciIndicator.Start(options);
double[][] outputs = [new double[high.Length - lookback]];
cciIndicator.Run(inputs, options, outputs);
double[] tulipResult = outputs[0];
// Check last 10 values match
int checkCount = Math.Min(10, tulipResult.Length);
for (int i = tulipResult.Length - checkCount; i < tulipResult.Length; i++)
{
int qIdx = i + lookback;
Assert.Equal(tulipResult[i], qResult[qIdx].Value, 1e-6);
}
streamingResults.Add(cci.Update(bar).Value);
}
_output.WriteLine("All periods validated against Tulip");
Assert.Equal(batchResult.Count, streamingResults.Count);
int count = batchResult.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
Assert.Equal(batchResult[i].Value, streamingResults[i], 1e-10);
}
_output.WriteLine("CCI Batch vs Streaming consistency validated");
}
#endregion
+11 -1
View File
@@ -66,7 +66,17 @@ public sealed class Cci : ITValuePublisher
/// <summary>
/// Number of bars required for warmup.
/// </summary>
public static int WarmupPeriod => DefaultPeriod;
public int WarmupPeriod => _period;
/// <summary>
/// Returns the default warmup period (<see cref="DefaultPeriod"/>).
/// </summary>
/// <remarks>
/// This static accessor is provided for backward compatibility. Prefer the instance
/// <see cref="WarmupPeriod"/> property which returns the actual configured period.
/// </remarks>
[Obsolete("Use the instance WarmupPeriod property instead. This static accessor returns the default period (20) and will be removed in a future major version.")]
public static int DefaultWarmupPeriod => DefaultPeriod;
/// <summary>
/// Creates a CCI indicator with specified period.
+167 -180
View File
@@ -1,272 +1,259 @@
using Skender.Stock.Indicators;
using Xunit;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for CMO against external libraries.
/// Validation tests for CMO (Chande Momentum Oscillator) against external libraries.
/// CMO = 100 × (SumUp - SumDown) / (SumUp + SumDown)
///
/// Note: TALib CMO uses Wilder's exponential smoothing internally, which produces
/// fundamentally different results than the standard simple-sum CMO formula.
/// QuanTAlib, Tulip, and Skender all use the standard simple-sum approach.
/// </summary>
public class CmoValidationTests
public sealed class CmoValidationTests(ITestOutputHelper output) : IDisposable
{
private const double Epsilon = 1e-9;
private readonly ValidationTestData _testData = new();
private readonly ITestOutputHelper _output = output;
private bool _disposed;
// ═══════════════════════════════════════════════════════════════════════════
// Tulip Indicators Validation
// ═══════════════════════════════════════════════════════════════════════════
private const int TestPeriod = 14;
public void Dispose()
{
Dispose(disposing: true);
}
private void Dispose(bool disposing)
{
if (_disposed) { return; }
_disposed = true;
if (disposing) { _testData?.Dispose(); }
}
#region Tulip Validation
[Fact]
public void Cmo_MatchesTulip_StandardData()
public void Cmo_MatchesTulip_Batch()
{
// Generate test data
double[] prices = new double[50];
for (int i = 0; i < prices.Length; i++)
{
prices[i] = 100 + Math.Sin(i * 0.3) * 10 + i * 0.1;
}
double[] tData = _testData.RawData.ToArray();
int period = 14;
double[] qOutput = new double[tData.Length];
Cmo.Batch(tData.AsSpan(), qOutput.AsSpan(), TestPeriod);
// Calculate using Tulip
// Tulip cmo
var cmoIndicator = Tulip.Indicators.cmo;
double[][] inputs = [prices];
double[] options = [period];
double[][] inputs = [tData];
double[] options = [TestPeriod];
int lookback = cmoIndicator.Start(options);
double[][] outputs = [new double[prices.Length - lookback]];
double[][] outputs = [new double[tData.Length - lookback]];
cmoIndicator.Run(inputs, options, outputs);
double[] tulipOutput = outputs[0];
double[] tulipResult = outputs[0];
// Calculate using our CMO
double[] ourOutput = new double[prices.Length];
Cmo.Batch(prices, ourOutput, period);
ValidationHelper.VerifyData(qOutput, tulipResult, lookback);
// Compare results - Tulip outputs from index 0 corresponding to our index period
for (int i = 0; i < tulipOutput.Length; i++)
{
Assert.Equal(tulipOutput[i], ourOutput[i + lookback], Epsilon);
}
_output.WriteLine("CMO Batch validated successfully against Tulip");
}
[Fact]
public void Cmo_MatchesTulip_UpwardTrend()
public void Cmo_MatchesTulip_Streaming()
{
// Steadily increasing prices
double[] prices = new double[30];
for (int i = 0; i < prices.Length; i++)
double[] tData = _testData.RawData.ToArray();
// QuanTAlib CMO (streaming)
var cmo = new Cmo(TestPeriod);
var qResults = new List<double>();
foreach (var item in _testData.Data)
{
prices[i] = 100 + i * 2;
qResults.Add(cmo.Update(item).Value);
}
int period = 10;
// Tulip cmo
var cmoIndicator = Tulip.Indicators.cmo;
double[][] inputs = [tData];
double[] options = [TestPeriod];
int lookback = cmoIndicator.Start(options);
double[][] outputs = [new double[tData.Length - lookback]];
cmoIndicator.Run(inputs, options, outputs);
double[] tulipResult = outputs[0];
ValidationHelper.VerifyData(qResults, tulipResult, lookback);
_output.WriteLine("CMO Streaming validated successfully against Tulip");
}
[Theory]
[InlineData(5)]
[InlineData(10)]
[InlineData(20)]
[InlineData(30)]
public void Cmo_MatchesTulip_DifferentPeriods(int period)
{
double[] tData = _testData.RawData.ToArray();
double[] qOutput = new double[tData.Length];
Cmo.Batch(tData.AsSpan(), qOutput.AsSpan(), period);
var cmoIndicator = Tulip.Indicators.cmo;
double[][] inputs = [prices];
double[][] inputs = [tData];
double[] options = [period];
int lookback = cmoIndicator.Start(options);
double[][] outputs = [new double[prices.Length - lookback]];
double[][] outputs = [new double[tData.Length - lookback]];
cmoIndicator.Run(inputs, options, outputs);
double[] tulipOutput = outputs[0];
double[] tulipResult = outputs[0];
double[] ourOutput = new double[prices.Length];
Cmo.Batch(prices, ourOutput, period);
ValidationHelper.VerifyData(qOutput, tulipResult, lookback);
}
for (int i = 0; i < tulipOutput.Length; i++)
{
Assert.Equal(tulipOutput[i], ourOutput[i + lookback], Epsilon);
}
#endregion
#region Skender Validation
[Fact]
public void Cmo_MatchesSkender_Batch()
{
// QuanTAlib CMO (batch)
var qResult = Cmo.Batch(_testData.Data, TestPeriod);
// Skender CMO
var sResult = _testData.SkenderQuotes.GetCmo(TestPeriod).ToList();
// Compare last 100 records
ValidationHelper.VerifyData(qResult, sResult, (s) => s.Cmo);
_output.WriteLine("CMO Batch validated successfully against Skender");
}
[Fact]
public void Cmo_MatchesTulip_DownwardTrend()
public void Cmo_MatchesSkender_Streaming()
{
// Steadily decreasing prices
double[] prices = new double[30];
for (int i = 0; i < prices.Length; i++)
// QuanTAlib CMO (streaming)
var cmo = new Cmo(TestPeriod);
var qResults = new List<double>();
foreach (var item in _testData.Data)
{
prices[i] = 200 - i * 2;
qResults.Add(cmo.Update(item).Value);
}
int period = 10;
// Skender CMO
var sResult = _testData.SkenderQuotes.GetCmo(TestPeriod).ToList();
var cmoIndicator = Tulip.Indicators.cmo;
double[][] inputs = [prices];
double[] options = [period];
int lookback = cmoIndicator.Start(options);
double[][] outputs = [new double[prices.Length - lookback]];
cmoIndicator.Run(inputs, options, outputs);
double[] tulipOutput = outputs[0];
int count = qResults.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
double[] ourOutput = new double[prices.Length];
Cmo.Batch(prices, ourOutput, period);
for (int i = 0; i < tulipOutput.Length; i++)
for (int i = start; i < count; i++)
{
Assert.Equal(tulipOutput[i], ourOutput[i + lookback], Epsilon);
if (sResult[i].Cmo is null) { continue; }
Assert.True(
Math.Abs(qResults[i] - sResult[i].Cmo!.Value) <= ValidationHelper.SkenderTolerance,
$"Mismatch at index {i}: QuanTAlib={qResults[i]:G17}, Skender={sResult[i].Cmo:G17}");
}
_output.WriteLine("CMO Streaming validated successfully against Skender");
}
[Fact]
public void Cmo_MatchesTulip_MultiplePeriods()
[Theory]
[InlineData(5)]
[InlineData(10)]
[InlineData(20)]
[InlineData(30)]
public void Cmo_MatchesSkender_DifferentPeriods(int period)
{
double[] prices = new double[100];
var random = new Random(42);
for (int i = 0; i < prices.Length; i++)
{
prices[i] = 100 + (random.NextDouble() - 0.5) * 20 + i * 0.05;
}
var qResult = Cmo.Batch(_testData.Data, period);
int[] periods = [5, 10, 14, 20, 30];
var sResult = _testData.SkenderQuotes.GetCmo(period).ToList();
foreach (int period in periods)
{
var cmoIndicator = Tulip.Indicators.cmo;
double[][] inputs = [prices];
double[] options = [period];
int lookback = cmoIndicator.Start(options);
double[][] outputs = [new double[prices.Length - lookback]];
cmoIndicator.Run(inputs, options, outputs);
double[] tulipOutput = outputs[0];
double[] ourOutput = new double[prices.Length];
Cmo.Batch(prices, ourOutput, period);
for (int i = 0; i < tulipOutput.Length; i++)
{
Assert.Equal(tulipOutput[i], ourOutput[i + lookback], Epsilon);
}
}
ValidationHelper.VerifyData(qResult, sResult, (s) => s.Cmo);
}
// ═══════════════════════════════════════════════════════════════════════════
// Manual Calculation Validation
// ═══════════════════════════════════════════════════════════════════════════
#endregion
#region Mathematical Validation
[Fact]
public void Cmo_ManualCalculation_AllUpMoves()
public void Cmo_AllUpMoves_Returns100()
{
// All upward moves
double[] prices = [100, 101, 102, 103, 104, 105];
int period = 5;
double[] output = new double[prices.Length];
Cmo.Batch(prices, output, period);
double[] result = new double[prices.Length];
Cmo.Batch(prices, result, period);
// After 5 periods: SumUp = 5, SumDown = 0
// CMO = 100 * (5-0)/(5+0) = 100
Assert.Equal(100.0, output[5], Epsilon);
// After 5 periods: SumUp = 5, SumDown = 0 → CMO = 100
Assert.Equal(100.0, result[5], 1e-9);
}
[Fact]
public void Cmo_ManualCalculation_AllDownMoves()
public void Cmo_AllDownMoves_ReturnsNegative100()
{
// All downward moves
double[] prices = [105, 104, 103, 102, 101, 100];
int period = 5;
double[] output = new double[prices.Length];
Cmo.Batch(prices, output, period);
double[] result = new double[prices.Length];
Cmo.Batch(prices, result, period);
// After 5 periods: SumUp = 0, SumDown = 5
// CMO = 100 * (0-5)/(0+5) = -100
Assert.Equal(-100.0, output[5], Epsilon);
// After 5 periods: SumUp = 0, SumDown = 5 → CMO = -100
Assert.Equal(-100.0, result[5], 1e-9);
}
[Fact]
public void Cmo_ManualCalculation_EqualMoves()
public void Cmo_EqualMoves_ReturnsZero()
{
// Equal up and down moves
double[] prices = [100, 102, 100, 102, 100]; // up 2, down 2, up 2, down 2
int period = 4;
double[] output = new double[prices.Length];
Cmo.Batch(prices, output, period);
double[] result = new double[prices.Length];
Cmo.Batch(prices, result, period);
// SumUp = 4, SumDown = 4
// CMO = 100 * (4-4)/(4+4) = 0
Assert.Equal(0.0, output[4], Epsilon);
}
// ═══════════════════════════════════════════════════════════════════════════
// Streaming vs Batch Validation
// ═══════════════════════════════════════════════════════════════════════════
[Fact]
public void Cmo_StreamingMatchesBatch()
{
double[] prices = new double[100];
var random = new Random(12345);
for (int i = 0; i < prices.Length; i++)
{
prices[i] = 100 + (random.NextDouble() - 0.5) * 30 + Math.Sin(i * 0.2) * 5;
}
int period = 14;
// Batch calculation
double[] batchOutput = new double[prices.Length];
Cmo.Batch(prices, batchOutput, period);
// Streaming calculation
var cmo = new Cmo(period);
for (int i = 0; i < prices.Length; i++)
{
var result = cmo.Update(new TValue(DateTime.Now.Ticks + i, prices[i]));
Assert.Equal(batchOutput[i], result.Value, Epsilon);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Edge Case Validation
// ═══════════════════════════════════════════════════════════════════════════
[Fact]
public void Cmo_NoChange_ReturnsZero()
{
double[] prices = [100, 100, 100, 100, 100, 100];
int period = 5;
double[] output = new double[prices.Length];
Cmo.Batch(prices, output, period);
// No movement = 0
Assert.Equal(0.0, output[5]);
// SumUp = 4, SumDown = 4 → CMO = 0
Assert.Equal(0.0, result[4], 1e-9);
}
[Fact]
public void Cmo_RangeIsBounded()
{
double[] prices = new double[100];
var random = new Random(54321);
for (int i = 0; i < prices.Length; i++)
{
prices[i] = 100 + (random.NextDouble() - 0.5) * 50;
}
double[] tData = _testData.RawData.ToArray();
double[] output = new double[prices.Length];
Cmo.Batch(prices, output, 14);
double[] result = new double[tData.Length];
Cmo.Batch(tData.AsSpan(), result.AsSpan(), TestPeriod);
// All values should be in [-100, 100] range
for (int i = 14; i < output.Length; i++)
// All values after warmup should be in [-100, 100]
for (int i = TestPeriod; i < result.Length; i++)
{
Assert.True(output[i] >= -100.0 && output[i] <= 100.0,
$"CMO at index {i} = {output[i]} is out of range [-100, 100]");
Assert.True(result[i] >= -100.0 && result[i] <= 100.0,
$"CMO at index {i} = {result[i]} is out of range [-100, 100]");
}
}
[Fact]
public void Cmo_AlternatingMoves_ConvergesToZero()
public void Batch_MatchesStreaming_IdenticalResults()
{
// Alternating pattern with equal magnitude
double[] prices = new double[50];
for (int i = 0; i < prices.Length; i++)
double[] tData = _testData.RawData.ToArray();
// Batch
double[] batchOutput = new double[tData.Length];
Cmo.Batch(tData.AsSpan(), batchOutput.AsSpan(), TestPeriod);
// Streaming
var cmo = new Cmo(TestPeriod);
var streamingResults = new double[tData.Length];
for (int i = 0; i < tData.Length; i++)
{
prices[i] = 100 + (i % 2 == 0 ? 0 : 2); // 100, 102, 100, 102, ...
streamingResults[i] = cmo.Update(new TValue(DateTime.UtcNow.Ticks + i, tData[i])).Value;
}
double[] output = new double[prices.Length];
Cmo.Batch(prices, output, 10);
// Result should be close to 0 for balanced oscillation
Assert.True(Math.Abs(output[^1]) < 20,
$"CMO for alternating pattern should be near zero, got {output[^1]}");
int count = tData.Length;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
Assert.Equal(batchOutput[i], streamingResults[i], 1e-9);
}
_output.WriteLine("CMO Batch vs Streaming consistency validated");
}
#endregion
}
+256
View File
@@ -0,0 +1,256 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class MomIndicatorTests
{
[Fact]
public void MomIndicator_Constructor_SetsDefaults()
{
var indicator = new MomIndicator();
Assert.Equal(10, indicator.Period);
Assert.Equal(SourceType.Close, indicator.Source);
Assert.True(indicator.ShowColdValues);
Assert.Equal("MOM - Momentum", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.False(indicator.OnBackGround);
}
[Fact]
public void MomIndicator_MinHistoryDepths_IsPeriodPlusOne()
{
var indicator = new MomIndicator { Period = 10 };
Assert.Equal(11, indicator.MinHistoryDepths);
}
[Fact]
public void MomIndicator_ShortName_IncludesPeriod()
{
var indicator = new MomIndicator { Period = 5 };
Assert.Equal("MOM(5)", indicator.ShortName);
}
[Fact]
public void MomIndicator_Initialize_CreatesLineSeries()
{
var indicator = new MomIndicator();
indicator.Initialize();
Assert.Equal(2, indicator.LinesSeries.Count);
Assert.Equal("MOM", indicator.LinesSeries[0].Name);
Assert.Equal("Zero", indicator.LinesSeries[1].Name);
}
[Fact]
public void MomIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new MomIndicator();
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.Equal(1, indicator.LinesSeries[1].Count);
}
[Fact]
public void MomIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new MomIndicator();
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 MomIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
{
var indicator = new MomIndicator();
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 MomIndicator_MultipleUpdates_ProducesCorrectSequence()
{
var indicator = new MomIndicator();
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)));
Assert.Equal(0, indicator.LinesSeries[1].GetValue(i));
}
}
[Fact]
public void MomIndicator_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 MomIndicator { 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 MomIndicator_ShowColdValues_False_SetsNaN()
{
var indicator = new MomIndicator { 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 MomIndicator_Uptrend_ProducesPositiveMom()
{
var indicator = new MomIndicator { Period = 1 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
double price = 100 + i * 5;
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 2, price - 2, price);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double lastMom = indicator.LinesSeries[0].GetValue(0);
Assert.True(lastMom > 0);
}
[Fact]
public void MomIndicator_Downtrend_ProducesNegativeMom()
{
var indicator = new MomIndicator { Period = 1 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
double price = 200 - i * 5;
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 2, price - 2, price);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double lastMom = indicator.LinesSeries[0].GetValue(0);
Assert.True(lastMom < 0);
}
[Fact]
public void MomIndicator_FlatPrices_ProducesZeroMom()
{
var indicator = new MomIndicator { Period = 1 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 5; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 105, 95, 100);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double lastMom = indicator.LinesSeries[0].GetValue(0);
Assert.Equal(0, lastMom);
}
[Fact]
public void MomIndicator_KnownMom_Correct()
{
var indicator = new MomIndicator { Period = 1 };
indicator.Initialize();
var now = DateTime.UtcNow;
// Add bar at 100
indicator.HistoricalData.AddBar(now, 100, 100, 100, 100);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// Add bar at 110 (MOM = 110 - 100 = 10)
indicator.HistoricalData.AddBar(now.AddMinutes(1), 110, 110, 110, 110);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
double mom = indicator.LinesSeries[0].GetValue(0);
Assert.Equal(10, mom, 5);
}
[Fact]
public void MomIndicator_DifferentPeriods_Work()
{
var periods = new[] { 1, 5, 10, 20 };
foreach (var period in periods)
{
var indicator = new MomIndicator { Period = period };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < period + 5; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 102 + i, 98 + i, 101 + i);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
Assert.Equal(period + 5, indicator.LinesSeries[0].Count);
}
}
}
+85
View File
@@ -0,0 +1,85 @@
using System.Drawing;
using TradingPlatform.BusinessLayer;
using static QuanTAlib.IndicatorExtensions;
namespace QuanTAlib;
/// <summary>
/// MOM (Momentum) Quantower indicator.
/// Calculates absolute price change over a lookback period.
/// Formula: current - past
/// </summary>
public class MomIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", 0, 1, 999, 1, 0)]
public int Period { get; set; } = 10;
[DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show Cold Values", sortIndex: 100)]
public bool ShowColdValues { get; set; } = true;
private Mom? _mom;
private Func<IHistoryItem, double>? _selector;
public int MinHistoryDepths => Period + 1;
public override string ShortName => $"MOM({Period})";
public MomIndicator()
{
Name = "MOM - Momentum";
Description = "Calculates absolute price change: current - past";
SeparateWindow = true;
OnBackGround = false;
}
protected override void OnInit()
{
_mom = new Mom(Period);
_selector = Source.GetPriceSelector();
AddLineSeries(new LineSeries("MOM", IndicatorExtensions.Momentum, 2, LineStyle.Histogramm));
AddLineSeries(new LineSeries("Zero", Color.Gray, 1, LineStyle.Dot));
}
protected override void OnUpdate(UpdateArgs args)
{
if (_mom == null || _selector == null)
{
return;
}
var item = HistoricalData[0, SeekOriginHistory.End];
double value = _selector(item);
bool isNew = args.IsNewBar();
TValue input = new(item.TimeLeft, value);
_mom.Update(input, isNew);
bool isHot = _mom.IsHot;
LinesSeries[0].SetValue(_mom.Last.Value, isHot, ShowColdValues);
LinesSeries[1].SetValue(0);
if (isHot || ShowColdValues)
{
double mom = _mom.Last.Value;
Color color;
if (mom > 0)
{
color = Color.Green;
}
else if (mom < 0)
{
color = Color.Red;
}
else
{
color = Color.Gray;
}
LinesSeries[0].SetMarker(0, new IndicatorLineMarker(color));
}
}
}
+451
View File
@@ -0,0 +1,451 @@
using Xunit;
namespace QuanTAlib.Tests;
public class MomTests
{
private readonly TSeries _gbm;
private const int TestPeriod = 9;
private const int DataPoints = 100;
public MomTests()
{
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_WithValidPeriod_SetsProperties()
{
var mom = new Mom(TestPeriod);
Assert.Equal($"Mom({TestPeriod})", mom.Name);
Assert.Equal(TestPeriod + 1, mom.WarmupPeriod);
}
[Fact]
public void Constructor_WithZeroPeriod_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Mom(0));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Constructor_WithNegativePeriod_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Mom(-1));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Constructor_WithSource_SubscribesToEvents()
{
var source = new TSeries(DataPoints);
var mom = new Mom(source, TestPeriod);
Assert.NotNull(mom);
}
#endregion
#region Basic Calculation Tests
[Fact]
public void Update_ReturnsZeroDuringWarmup()
{
var mom = new Mom(TestPeriod);
var tv = mom.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.Equal(0.0, tv.Value);
}
[Fact]
public void Update_FirstValues_ReturnsZero()
{
var mom = new Mom(TestPeriod);
for (int i = 0; i < TestPeriod; i++)
{
var tv = mom.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
Assert.Equal(0.0, tv.Value);
}
}
[Fact]
public void Update_AfterWarmup_ReturnsAbsoluteChange()
{
var mom = new Mom(2); // period=2
var values = new double[] { 100, 102, 105, 103, 110 };
for (int i = 0; i < values.Length; i++)
{
var tv = mom.Update(new TValue(DateTime.UtcNow.AddSeconds(i), values[i]), true);
if (i < 2)
{
Assert.Equal(0.0, tv.Value); // warmup period
}
else
{
// absolute change: current - past
double expected = values[i] - values[i - 2];
Assert.Equal(expected, tv.Value, 10);
}
}
}
[Fact]
public void Update_ConstantInput_ReturnsZero()
{
var mom = new Mom(5);
for (int i = 0; i < 20; i++)
{
var tv = mom.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0), true);
Assert.Equal(0.0, tv.Value);
}
}
[Fact]
public void Last_IsAccessible()
{
var mom = new Mom(TestPeriod);
mom.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.Equal(0.0, mom.Last.Value);
}
[Fact]
public void IsHot_ReturnsFalseDuringWarmup()
{
var mom = new Mom(TestPeriod);
for (int i = 0; i < TestPeriod; i++)
{
mom.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
Assert.False(mom.IsHot);
}
}
[Fact]
public void IsHot_ReturnsTrueAfterWarmup()
{
var mom = new Mom(TestPeriod);
for (int i = 0; i <= TestPeriod; i++)
{
mom.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
}
Assert.True(mom.IsHot);
}
[Fact]
public void Name_IsAccessible()
{
var mom = new Mom(TestPeriod);
Assert.Equal($"Mom({TestPeriod})", mom.Name);
}
#endregion
#region State Management Tests
[Fact]
public void Update_WithIsNewTrue_AdvancesState()
{
var mom = new Mom(TestPeriod);
var time = DateTime.UtcNow;
mom.Update(new TValue(time, 100.0), true);
mom.Update(new TValue(time.AddSeconds(1), 105.0), true);
mom.Update(new TValue(time.AddSeconds(2), 110.0), true);
Assert.NotEqual(default, mom.Last);
}
[Fact]
public void Update_WithIsNewFalse_UpdatesCurrentState()
{
var mom = new Mom(2);
var time = DateTime.UtcNow;
mom.Update(new TValue(time, 100.0), true);
mom.Update(new TValue(time.AddSeconds(1), 102.0), true);
var first = mom.Update(new TValue(time.AddSeconds(2), 105.0), true);
var corrected = mom.Update(new TValue(time.AddSeconds(2), 108.0), false);
Assert.NotEqual(first.Value, corrected.Value);
// first: 105 - 100 = 5
// corrected: 108 - 100 = 8
Assert.Equal(5.0, first.Value, 10);
Assert.Equal(8.0, corrected.Value, 10);
}
[Fact]
public void Update_IterativeCorrections_RestoresPreviousState()
{
var mom = new Mom(2);
var time = DateTime.UtcNow;
mom.Update(new TValue(time, 100.0), true);
mom.Update(new TValue(time.AddSeconds(1), 102.0), true);
var baseline = mom.Update(new TValue(time.AddSeconds(2), 105.0), true);
mom.Update(new TValue(time.AddSeconds(2), 108.0), false);
mom.Update(new TValue(time.AddSeconds(2), 110.0), false);
var restored = mom.Update(new TValue(time.AddSeconds(2), 105.0), false);
Assert.Equal(baseline.Value, restored.Value, 10);
}
[Fact]
public void Reset_ClearsStateAndLastValidTracking()
{
var mom = new Mom(TestPeriod);
var time = DateTime.UtcNow;
for (int i = 0; i <= TestPeriod; i++)
{
mom.Update(new TValue(time.AddSeconds(i), 100.0 + i));
}
mom.Reset();
Assert.Equal(default, mom.Last);
Assert.False(mom.IsHot);
}
#endregion
#region Robustness Tests
[Fact]
public void Update_WithNaN_UsesLastValidValue()
{
var mom = new Mom(2);
var time = DateTime.UtcNow;
mom.Update(new TValue(time, 100.0), true);
mom.Update(new TValue(time.AddSeconds(1), 102.0), true);
_ = mom.Update(new TValue(time.AddSeconds(2), 105.0), true);
var afterNaN = mom.Update(new TValue(time.AddSeconds(3), double.NaN), true);
// NaN should use last valid (105), so change is 105 - 102 = 3
Assert.True(double.IsFinite(afterNaN.Value));
Assert.Equal(3.0, afterNaN.Value, 10);
}
[Fact]
public void Update_WithInfinity_UsesLastValidValue()
{
var mom = new Mom(2);
var time = DateTime.UtcNow;
mom.Update(new TValue(time, 100.0), true);
mom.Update(new TValue(time.AddSeconds(1), 102.0), true);
mom.Update(new TValue(time.AddSeconds(2), 105.0), true);
var afterInf = mom.Update(new TValue(time.AddSeconds(3), double.PositiveInfinity), true);
Assert.True(double.IsFinite(afterInf.Value));
}
[Fact]
public void Update_BatchNaN_HandlesSafely()
{
var mom = new Mom(TestPeriod);
var time = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
var value = i % 3 == 0 ? double.NaN : 100.0 + i;
var tv = mom.Update(new TValue(time.AddSeconds(i), value), true);
Assert.True(double.IsFinite(tv.Value));
}
}
#endregion
#region Consistency Tests (All 4 modes must match)
[Fact]
public void AllModes_ProduceSameResults()
{
// Mode 1: Batch via TSeries
var batchResult = Mom.Batch(_gbm, TestPeriod);
// Mode 2: Streaming
var streamingMom = new Mom(TestPeriod);
var streamingResult = new TSeries(DataPoints);
for (int i = 0; i < _gbm.Count; i++)
{
var tv = streamingMom.Update(new TValue(_gbm[i].Time, _gbm[i].Value), true);
streamingResult.Add(tv, true);
}
// Mode 3: Span-based
Span<double> spanOutput = stackalloc double[DataPoints];
Mom.Batch(_gbm.Values, spanOutput, TestPeriod);
// Mode 4: Event-driven
var eventMom = new Mom(TestPeriod);
var eventResult = new TSeries(DataPoints);
eventMom.Pub += (object? _, in TValueEventArgs e) => eventResult.Add(e.Value, e.IsNew);
for (int i = 0; i < _gbm.Count; i++)
{
eventMom.Update(new TValue(_gbm[i].Time, _gbm[i].Value), true);
}
// Compare all values
for (int i = 0; i < DataPoints; i++)
{
Assert.Equal(batchResult[i].Value, streamingResult[i].Value, 10);
Assert.Equal(batchResult[i].Value, spanOutput[i], 10);
Assert.Equal(batchResult[i].Value, eventResult[i].Value, 10);
}
}
#endregion
#region Span API Tests
[Fact]
public void Calculate_Span_ValidatesEmptySource()
{
var ex = Assert.Throws<ArgumentException>(() =>
{
ReadOnlySpan<double> empty = [];
Span<double> output = stackalloc double[1];
Mom.Batch(empty, output, TestPeriod);
});
Assert.Equal("source", ex.ParamName);
}
[Fact]
public void Calculate_Span_ValidatesOutputLength()
{
var ex = Assert.Throws<ArgumentException>(() =>
{
ReadOnlySpan<double> source = stackalloc double[] { 1, 2, 3, 4, 5 };
Span<double> output = stackalloc double[3]; // too short
Mom.Batch(source, output, TestPeriod);
});
Assert.Equal("output", 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];
Mom.Batch(source, output, 0);
});
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Calculate_Span_MatchesTSeries()
{
var batchResult = Mom.Batch(_gbm, TestPeriod);
Span<double> spanOutput = stackalloc double[DataPoints];
Mom.Batch(_gbm.Values, spanOutput, TestPeriod);
for (int i = 0; i < DataPoints; i++)
{
Assert.Equal(batchResult[i].Value, spanOutput[i], 10);
}
}
[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;
}
Mom.Batch(source, output, TestPeriod);
Assert.Equal(largeSize, output.Length);
}
#endregion
#region Chainability Tests
[Fact]
public void Pub_FiresOnUpdate()
{
var mom = new Mom(TestPeriod);
bool eventFired = false;
mom.Pub += (object? _, in TValueEventArgs e) => eventFired = true;
mom.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.True(eventFired);
}
[Fact]
public void EventBasedChaining_Works()
{
var source = new TSeries(10);
var mom = new Mom(source, 2);
var results = new List<double>();
mom.Pub += (object? _, in TValueEventArgs e) => results.Add(e.Value.Value);
for (int i = 0; i < 10; i++)
{
source.Add(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i), true);
}
Assert.Equal(10, results.Count);
}
#endregion
#region Calculate Method Tests
[Fact]
public void Calculate_ReturnsTupleWithResultsAndIndicator()
{
var (results, indicator) = Mom.Calculate(_gbm, TestPeriod);
Assert.Equal(DataPoints, results.Count);
Assert.NotNull(indicator);
Assert.True(indicator.IsHot);
}
[Fact]
public void Prime_InitializesState()
{
var mom = new Mom(TestPeriod);
double[] primeData = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110];
mom.Prime(primeData);
Assert.NotEqual(default, mom.Last);
Assert.True(mom.IsHot);
}
[Fact]
public void Prime_SameAsSequentialUpdates()
{
var mom1 = new Mom(3);
var mom2 = new Mom(3);
double[] data = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110];
mom1.Prime(data);
foreach (var value in data)
{
mom2.Update(new TValue(DateTime.MinValue, value));
}
Assert.Equal(mom1.Last.Value, mom2.Last.Value, 10);
}
#endregion
}
+396
View File
@@ -0,0 +1,396 @@
using Skender.Stock.Indicators;
using TALib;
using Xunit;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for MOM (Momentum) against external libraries.
/// MOM = Price - Price[N] (absolute change)
///
/// TALib's Mom and Tulip's mom both compute the same absolute change.
/// Skender's GetRoc returns RocResult with .Momentum property (absolute change).
/// </summary>
public sealed class MomValidationTests(ITestOutputHelper output) : IDisposable
{
private readonly ValidationTestData _testData = new();
private readonly ITestOutputHelper _output = output;
private bool _disposed;
private const int TestPeriod = 10;
public void Dispose()
{
Dispose(disposing: true);
}
private void Dispose(bool disposing)
{
if (_disposed) { return; }
_disposed = true;
if (disposing) { _testData?.Dispose(); }
}
#region TALib Validation
[Fact]
public void Mom_MatchesTalib_Batch()
{
double[] tData = _testData.RawData.ToArray();
// QuanTAlib MOM (batch TSeries)
var qResult = Mom.Batch(_testData.Data, TestPeriod);
// TALib Mom
double[] tOutput = new double[tData.Length];
var retCode = TALib.Functions.Mom<double>(tData, 0..^0, tOutput, out var outRange, TestPeriod);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = TALib.Functions.MomLookback(TestPeriod);
int count = qResult.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
var (offset, length) = outRange.GetOffsetAndLength(tOutput.Length);
for (int i = start; i < count; i++)
{
if (i < lookback) { continue; }
int tIndex = i - offset;
if (tIndex < 0 || tIndex >= length) { continue; }
Assert.True(
Math.Abs(qResult[i].Value - tOutput[tIndex]) <= ValidationHelper.TalibTolerance,
$"Mismatch at index {i}: QuanTAlib={qResult[i].Value:G17}, TALib={tOutput[tIndex]:G17}");
}
_output.WriteLine("MOM Batch validated successfully against TALib");
}
[Fact]
public void Mom_MatchesTalib_Span()
{
double[] tData = _testData.RawData.ToArray();
// QuanTAlib MOM (Span)
double[] qOutput = new double[tData.Length];
Mom.Batch(tData.AsSpan(), qOutput.AsSpan(), TestPeriod);
// TALib Mom
double[] tOutput = new double[tData.Length];
var retCode = TALib.Functions.Mom<double>(tData, 0..^0, tOutput, out var outRange, TestPeriod);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = TALib.Functions.MomLookback(TestPeriod);
ValidationHelper.VerifyData(qOutput, tOutput, outRange, lookback);
_output.WriteLine("MOM Span validated successfully against TALib");
}
[Fact]
public void Mom_MatchesTalib_Streaming()
{
double[] tData = _testData.RawData.ToArray();
// QuanTAlib MOM (streaming)
var mom = new Mom(TestPeriod);
var qResults = new List<double>();
foreach (var item in _testData.Data)
{
qResults.Add(mom.Update(item).Value);
}
// TALib Mom
double[] tOutput = new double[tData.Length];
var retCode = TALib.Functions.Mom<double>(tData, 0..^0, tOutput, out var outRange, TestPeriod);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = TALib.Functions.MomLookback(TestPeriod);
ValidationHelper.VerifyData(qResults, tOutput, outRange, lookback);
_output.WriteLine("MOM Streaming validated successfully against TALib");
}
[Theory]
[InlineData(1)]
[InlineData(5)]
[InlineData(14)]
[InlineData(20)]
[InlineData(50)]
public void Mom_MatchesTalib_DifferentPeriods(int period)
{
double[] tData = _testData.RawData.ToArray();
var qResult = Mom.Batch(_testData.Data, period);
double[] tOutput = new double[tData.Length];
var retCode = TALib.Functions.Mom<double>(tData, 0..^0, tOutput, out var outRange, period);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = TALib.Functions.MomLookback(period);
int count = qResult.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
var (offset, length) = outRange.GetOffsetAndLength(tOutput.Length);
for (int i = start; i < count; i++)
{
if (i < lookback) { continue; }
int tIndex = i - offset;
if (tIndex < 0 || tIndex >= length) { continue; }
Assert.True(
Math.Abs(qResult[i].Value - tOutput[tIndex]) <= ValidationHelper.TalibTolerance,
$"Period {period}, index {i}: QuanTAlib={qResult[i].Value:G17}, TALib={tOutput[tIndex]:G17}");
}
_output.WriteLine($"MOM period={period} validated against TALib");
}
#endregion
#region Tulip Validation
[Fact]
public void Mom_MatchesTulip_Batch()
{
double[] tData = _testData.RawData.ToArray();
var qResult = Mom.Batch(_testData.Data, TestPeriod);
// Tulip mom
var momIndicator = Tulip.Indicators.mom;
double[][] inputs = [tData];
double[] options = [TestPeriod];
int lookback = momIndicator.Start(options);
double[][] outputs = [new double[tData.Length - lookback]];
momIndicator.Run(inputs, options, outputs);
double[] tulipResult = outputs[0];
ValidationHelper.VerifyData(qResult, tulipResult, lookback);
_output.WriteLine("MOM Batch validated successfully against Tulip");
}
[Fact]
public void Mom_MatchesTulip_Streaming()
{
double[] tData = _testData.RawData.ToArray();
// QuanTAlib MOM (streaming)
var mom = new Mom(TestPeriod);
var qResults = new List<double>();
foreach (var item in _testData.Data)
{
qResults.Add(mom.Update(item).Value);
}
// Tulip mom
var momIndicator = Tulip.Indicators.mom;
double[][] inputs = [tData];
double[] options = [TestPeriod];
int lookback = momIndicator.Start(options);
double[][] outputs = [new double[tData.Length - lookback]];
momIndicator.Run(inputs, options, outputs);
double[] tulipResult = outputs[0];
ValidationHelper.VerifyData(qResults, tulipResult, lookback);
_output.WriteLine("MOM Streaming validated successfully against Tulip");
}
[Fact]
public void Mom_MatchesTulip_Span()
{
double[] tData = _testData.RawData.ToArray();
double[] qOutput = new double[tData.Length];
Mom.Batch(tData.AsSpan(), qOutput.AsSpan(), TestPeriod);
// Tulip mom
var momIndicator = Tulip.Indicators.mom;
double[][] inputs = [tData];
double[] options = [TestPeriod];
int lookback = momIndicator.Start(options);
double[][] outputs = [new double[tData.Length - lookback]];
momIndicator.Run(inputs, options, outputs);
double[] tulipResult = outputs[0];
ValidationHelper.VerifyData(qOutput, tulipResult, lookback);
_output.WriteLine("MOM Span validated successfully against Tulip");
}
[Theory]
[InlineData(1)]
[InlineData(5)]
[InlineData(20)]
[InlineData(50)]
public void Mom_MatchesTulip_DifferentPeriods(int period)
{
double[] tData = _testData.RawData.ToArray();
var qResult = Mom.Batch(_testData.Data, period);
var momIndicator = Tulip.Indicators.mom;
double[][] inputs = [tData];
double[] options = [period];
int lookback = momIndicator.Start(options);
double[][] outputs = [new double[tData.Length - lookback]];
momIndicator.Run(inputs, options, outputs);
double[] tulipResult = outputs[0];
ValidationHelper.VerifyData(qResult, tulipResult, lookback);
}
#endregion
#region Skender Validation
[Fact]
public void Mom_MatchesSkender_Batch()
{
// QuanTAlib MOM
var qResult = Mom.Batch(_testData.Data, TestPeriod);
// Skender GetRoc returns RocResult with .Momentum (absolute change = current - past)
var sResult = _testData.SkenderQuotes.GetRoc(TestPeriod).ToList();
// Compare last 100 records
ValidationHelper.VerifyData(qResult, sResult, (s) => s.Momentum);
_output.WriteLine("MOM Batch validated successfully against Skender (GetRoc.Momentum)");
}
[Fact]
public void Mom_MatchesSkender_Streaming()
{
// QuanTAlib MOM (streaming)
var mom = new Mom(TestPeriod);
var qResults = new List<double>();
foreach (var item in _testData.Data)
{
qResults.Add(mom.Update(item).Value);
}
// Skender GetRoc
var sResult = _testData.SkenderQuotes.GetRoc(TestPeriod).ToList();
int count = qResults.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
if (sResult[i].Momentum is null) { continue; }
Assert.True(
Math.Abs(qResults[i] - sResult[i].Momentum!.Value) <= ValidationHelper.SkenderTolerance,
$"Mismatch at index {i}: QuanTAlib={qResults[i]:G17}, Skender={sResult[i].Momentum:G17}");
}
_output.WriteLine("MOM Streaming validated successfully against Skender (GetRoc.Momentum)");
}
[Theory]
[InlineData(1)]
[InlineData(5)]
[InlineData(20)]
[InlineData(50)]
public void Mom_MatchesSkender_DifferentPeriods(int period)
{
var qResult = Mom.Batch(_testData.Data, period);
var sResult = _testData.SkenderQuotes.GetRoc(period).ToList();
ValidationHelper.VerifyData(qResult, sResult, (s) => s.Momentum);
}
#endregion
#region Mathematical Validation
[Fact]
public void Mom_ManualCalculation_MatchesExpected()
{
var mom = new Mom(3);
var time = DateTime.UtcNow;
var values = new double[] { 100, 105, 110, 115, 120, 125 };
for (int i = 0; i < values.Length; i++)
{
var result = mom.Update(new TValue(time.AddSeconds(i), values[i]), true);
if (i >= 3)
{
double expected = values[i] - values[i - 3];
Assert.Equal(expected, result.Value, 10);
}
}
}
[Fact]
public void Mom_ConstantValues_ReturnsZero()
{
var constantData = new TSeries(100);
for (int i = 0; i < 100; i++)
{
constantData.Add(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0), true);
}
var result = Mom.Batch(constantData, TestPeriod);
for (int i = TestPeriod; i < 100; i++)
{
Assert.Equal(0.0, result[i].Value, 1e-10);
}
}
[Fact]
public void Mom_LinearIncrease_ReturnsConstant()
{
var linearData = new TSeries(100);
for (int i = 0; i < 100; i++)
{
linearData.Add(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i), true);
}
var result = Mom.Batch(linearData, TestPeriod);
// Linear increase by 1 per bar → MOM = period after warmup
for (int i = TestPeriod; i < 100; i++)
{
Assert.Equal(TestPeriod, result[i].Value, 1e-10);
}
}
[Fact]
public void Batch_MatchesStreaming_IdenticalResults()
{
var source = _testData.Data;
// Streaming
var streamingMom = new Mom(TestPeriod);
var streamingResults = new List<double>();
for (int i = 0; i < source.Count; i++)
{
streamingResults.Add(streamingMom.Update(source[i]).Value);
}
// Batch
var batchResult = Mom.Batch(source, TestPeriod);
int count = source.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
Assert.Equal(batchResult[i].Value, streamingResults[i], ValidationHelper.DefaultTolerance);
}
_output.WriteLine("MOM Batch vs Streaming consistency validated");
}
#endregion
}
+187
View File
@@ -0,0 +1,187 @@
using System.Runtime.CompilerServices;
namespace QuanTAlib;
/// <summary>
/// Computes the Momentum (MOM), which measures the absolute price change over a specified lookback period.
/// </summary>
/// <remarks>
/// MOM Formula:
/// <c>MOM = Price - Price[N]</c>.
///
/// Positive values indicate upward momentum; negative values indicate downward momentum.
/// This implementation is optimized for streaming updates with O(1) per bar.
/// 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="mom.pine">Reference Pine Script implementation</seealso>
[SkipLocalsInit]
public sealed class Mom : AbstractBase
{
private readonly int _period;
private readonly RingBuffer _buffer;
private record struct State(double LastValid);
private State _state, _p_state;
private ITValuePublisher? _source;
private bool _disposed;
/// <summary>
/// True when the buffer has enough data to compute valid momentum values.
/// </summary>
public override bool IsHot => _buffer.Count > _period;
/// <summary>
/// Initializes a new Momentum indicator with specified lookback period.
/// </summary>
/// <param name="period">Lookback period (must be >= 1)</param>
public Mom(int period = 10)
{
if (period < 1)
{
throw new ArgumentException("Period must be >= 1", nameof(period));
}
_period = period;
_buffer = new RingBuffer(period + 1);
Name = $"Mom({period})";
WarmupPeriod = period + 1;
}
/// <summary>
/// Initializes a new Momentum indicator with source for event-based chaining.
/// </summary>
/// <param name="source">Source indicator for chaining</param>
/// <param name="period">Lookback period</param>
public Mom(ITValuePublisher source, int period = 10) : this(period)
{
_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);
_buffer.Add(value, isNew);
double result;
if (_buffer.Count <= _period)
{
result = 0.0;
}
else
{
double past = _buffer[0];
result = value - past;
}
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 = 10)
{
var indicator = new Mom(period);
return indicator.Update(source);
}
/// <summary>
/// Calculates momentum (absolute change) over a span of values.
/// Zero-allocation method for maximum performance.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period = 10)
{
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));
}
for (int i = 0; i < source.Length; i++)
{
output[i] = i < period ? 0.0 : source[i] - source[i - period];
}
}
public static (TSeries Results, Mom Indicator) Calculate(TSeries source, int period = 10)
{
var indicator = new Mom(period);
TSeries results = indicator.Update(source);
return (results, indicator);
}
public override void Reset()
{
_buffer.Clear();
_state = default;
_p_state = default;
Last = default;
}
protected override void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing && _source != null)
{
_source.Pub -= HandleUpdate;
_source = null;
}
_disposed = true;
}
base.Dispose(disposing);
}
}
+158
View File
@@ -0,0 +1,158 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class PmoIndicatorTests
{
[Fact]
public void PmoIndicator_Constructor_SetsDefaults()
{
var indicator = new PmoIndicator();
Assert.Equal("PMO - Price Momentum Oscillator", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
Assert.Equal(35, indicator.RocPeriod);
Assert.Equal(20, indicator.Smooth1Period);
Assert.Equal(10, indicator.Smooth2Period);
}
[Fact]
public void PmoIndicator_MinHistoryDepths_IsZero()
{
var indicator = new PmoIndicator();
Assert.Equal(0, PmoIndicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void PmoIndicator_ShortName_IncludesPeriods()
{
var indicator = new PmoIndicator();
indicator.Initialize();
Assert.Equal("PMO(35,20,10):Close", indicator.ShortName);
}
[Fact]
public void PmoIndicator_SourceCodeLink_IsValid()
{
var indicator = new PmoIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Pmo.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void PmoIndicator_Initialize_CreatesLineSeries()
{
var indicator = new PmoIndicator();
indicator.Initialize();
Assert.Equal(2, indicator.LinesSeries.Count);
Assert.Equal("PMO", indicator.LinesSeries[0].Name);
Assert.Equal("Zero", indicator.LinesSeries[1].Name);
}
[Fact]
public void PmoIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new PmoIndicator
{
RocPeriod = 5,
Smooth1Period = 3,
Smooth2Period = 3,
};
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 105, 95, 100 + i);
}
var args = new UpdateArgs(UpdateReason.HistoricalBar);
for (int i = 0; i < 20; i++)
{
indicator.ProcessUpdate(args);
}
double pmo = indicator.LinesSeries[0].GetValue(0);
Assert.False(double.IsNaN(pmo));
}
[Fact]
public void PmoIndicator_MultipleUpdates_ProducesFiniteSequence()
{
var indicator = new PmoIndicator
{
RocPeriod = 3,
Smooth1Period = 3,
Smooth2Period = 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.Equal(0, indicator.LinesSeries[1].GetValue(i));
}
}
[Fact]
public void PmoIndicator_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 PmoIndicator { 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 PmoIndicator_ShowColdValues_False_SetsNaN()
{
var indicator = new PmoIndicator { 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)));
}
}
+69
View File
@@ -0,0 +1,69 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
/// <summary>
/// PMO (Price Momentum Oscillator) Quantower indicator.
/// Double-smoothed rate of change measuring momentum with reduced noise.
/// Formula: ROC% → EMA → EMA
/// </summary>
[SkipLocalsInit]
public sealed class PmoIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("ROC Period", sortIndex: 1, 1, 2000, 1, 0)]
public int RocPeriod { get; set; } = 35;
[InputParameter("Smooth1 Period", sortIndex: 2, 1, 2000, 1, 0)]
public int Smooth1Period { get; set; } = 20;
[InputParameter("Smooth2 Period", sortIndex: 3, 1, 2000, 1, 0)]
public int Smooth2Period { get; set; } = 10;
[IndicatorExtensions.DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Pmo _pmo = null!;
private string _sourceName = null!;
private Func<IHistoryItem, double> _priceSelector = null!;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"PMO({RocPeriod},{Smooth1Period},{Smooth2Period}):{_sourceName}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/momentum/pmo/Pmo.Quantower.cs";
public PmoIndicator()
{
OnBackGround = true;
SeparateWindow = true;
_sourceName = Source.ToString();
Name = "PMO - Price Momentum Oscillator";
Description = "Double-smoothed rate of change for momentum analysis";
AddLineSeries(new LineSeries(name: "PMO", color: Color.Blue, width: 2, style: LineStyle.Solid));
AddLineSeries(new LineSeries(name: "Zero", color: Color.Gray, width: 1, style: LineStyle.Dot));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_pmo = new Pmo(RocPeriod, Smooth1Period, Smooth2Period);
_sourceName = Source.ToString();
_priceSelector = Source.GetPriceSelector();
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
TValue result = _pmo.Update(new TValue(this.GetInputBar(args).Time, _priceSelector(HistoricalData[Count - 1, SeekOriginHistory.Begin])), args.IsNewBar());
LinesSeries[0].SetValue(result.Value, _pmo.IsHot, ShowColdValues);
LinesSeries[1].SetValue(0);
}
}
+457
View File
@@ -0,0 +1,457 @@
using Xunit;
namespace QuanTAlib.Tests;
public class PmoTests
{
private readonly TSeries _gbm;
private const int TestTimePeriods = 10;
private const int TestSmoothPeriods = 5;
private const int TestSignalPeriods = 3;
private const int DataPoints = 100;
public PmoTests()
{
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 pmo = new Pmo(TestTimePeriods, TestSmoothPeriods, TestSignalPeriods);
Assert.Equal($"Pmo({TestTimePeriods},{TestSmoothPeriods},{TestSignalPeriods})", pmo.Name);
Assert.Equal(TestTimePeriods + TestSmoothPeriods, pmo.WarmupPeriod);
}
[Fact]
public void Constructor_DefaultParams_UsesStandardValues()
{
var pmo = new Pmo();
Assert.Equal("Pmo(35,20,10)", pmo.Name);
Assert.Equal(55, pmo.WarmupPeriod);
}
[Fact]
public void Constructor_WithZeroRocPeriod_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Pmo(0, 5, 3));
Assert.Equal("timePeriods", ex.ParamName);
}
[Fact]
public void Constructor_WithZeroSmooth1Period_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Pmo(10, 0, 3));
Assert.Equal("smoothPeriods", ex.ParamName);
}
[Fact]
public void Constructor_WithZeroSmooth2Period_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Pmo(10, 5, 0));
Assert.Equal("signalPeriods", ex.ParamName);
}
[Fact]
public void Constructor_WithNegativePeriod_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Pmo(-1, 5, 3));
Assert.Equal("timePeriods", ex.ParamName);
}
[Fact]
public void Constructor_WithSource_SubscribesToEvents()
{
var source = new TSeries(DataPoints);
var pmo = new Pmo(source, TestTimePeriods, TestSmoothPeriods, TestSignalPeriods);
Assert.NotNull(pmo);
}
#endregion
#region Basic Calculation Tests
[Fact]
public void Update_FirstValue_ReturnsFinite()
{
var pmo = new Pmo(TestTimePeriods, TestSmoothPeriods, TestSignalPeriods);
var tv = pmo.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.True(double.IsFinite(tv.Value));
}
[Fact]
public void Update_ConstantInput_ConvergesToZero()
{
var pmo = new Pmo(5, 3, 3);
for (int i = 0; i < 50; i++)
{
pmo.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0), true);
}
// Constant price → ROC% = 0 → PMO → 0
Assert.True(Math.Abs(pmo.Last.Value) < 1e-6,
$"PMO with constant input should converge to 0, got {pmo.Last.Value}");
}
[Fact]
public void Update_RisingPrices_ReturnsPositive()
{
var pmo = new Pmo(5, 3, 3);
for (int i = 0; i < 30; i++)
{
pmo.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i * 2.0), true);
}
Assert.True(pmo.Last.Value > 0,
$"PMO should be positive with rising prices, got {pmo.Last.Value}");
}
[Fact]
public void Update_FallingPrices_ReturnsNegative()
{
var pmo = new Pmo(5, 3, 3);
for (int i = 0; i < 30; i++)
{
pmo.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 200.0 - i * 2.0), true);
}
Assert.True(pmo.Last.Value < 0,
$"PMO should be negative with falling prices, got {pmo.Last.Value}");
}
[Fact]
public void Last_IsAccessible()
{
var pmo = new Pmo(TestTimePeriods, TestSmoothPeriods, TestSignalPeriods);
pmo.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.True(double.IsFinite(pmo.Last.Value));
}
[Fact]
public void IsHot_ReturnsFalseDuringWarmup()
{
var pmo = new Pmo(TestTimePeriods, TestSmoothPeriods, TestSignalPeriods);
var warmup = TestTimePeriods + TestSmoothPeriods;
for (int i = 0; i < warmup; i++)
{
pmo.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
Assert.False(pmo.IsHot, $"Should not be hot at bar {i}");
}
}
[Fact]
public void IsHot_ReturnsTrueAfterWarmup()
{
var pmo = new Pmo(TestTimePeriods, TestSmoothPeriods, TestSignalPeriods);
var warmup = TestTimePeriods + TestSmoothPeriods;
for (int i = 0; i <= warmup; i++)
{
pmo.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
}
Assert.True(pmo.IsHot);
}
#endregion
#region State Management Tests
[Fact]
public void Update_WithIsNewTrue_AdvancesState()
{
var pmo = new Pmo(TestTimePeriods, TestSmoothPeriods, TestSignalPeriods);
var time = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
pmo.Update(new TValue(time.AddSeconds(i), 100.0 + i), true);
}
Assert.NotEqual(default, pmo.Last);
}
[Fact]
public void Update_WithIsNewFalse_RollsBackState()
{
var pmo = new Pmo(5, 3, 3);
var time = DateTime.UtcNow;
// Build up state
for (int i = 0; i < 20; i++)
{
pmo.Update(new TValue(time.AddSeconds(i), 100.0 + i * 0.5), true);
}
var baseline = pmo.Update(new TValue(time.AddSeconds(20), 120.0), true);
var corrected = pmo.Update(new TValue(time.AddSeconds(20), 115.0), false);
Assert.NotEqual(baseline.Value, corrected.Value);
}
[Fact]
public void Update_IterativeCorrections_RestoresPreviousState()
{
var pmo = new Pmo(5, 3, 3);
var time = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
pmo.Update(new TValue(time.AddSeconds(i), 100.0 + i * 0.5), true);
}
var baseline = pmo.Update(new TValue(time.AddSeconds(20), 120.0), true);
// Several corrections
pmo.Update(new TValue(time.AddSeconds(20), 130.0), false);
pmo.Update(new TValue(time.AddSeconds(20), 110.0), false);
var restored = pmo.Update(new TValue(time.AddSeconds(20), 120.0), false);
Assert.Equal(baseline.Value, restored.Value, 10);
}
[Fact]
public void Reset_ClearsState()
{
var pmo = new Pmo(TestTimePeriods, TestSmoothPeriods, TestSignalPeriods);
for (int i = 0; i < 30; i++)
{
pmo.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
}
pmo.Reset();
Assert.Equal(default, pmo.Last);
Assert.False(pmo.IsHot);
}
#endregion
#region Robustness Tests
[Fact]
public void Update_WithNaN_UsesLastValidValue()
{
var pmo = new Pmo(5, 3, 3);
var time = DateTime.UtcNow;
for (int i = 0; i < 15; i++)
{
pmo.Update(new TValue(time.AddSeconds(i), 100.0 + i), true);
}
var afterNaN = pmo.Update(new TValue(time.AddSeconds(15), double.NaN), true);
Assert.True(double.IsFinite(afterNaN.Value));
}
[Fact]
public void Update_WithInfinity_UsesLastValidValue()
{
var pmo = new Pmo(5, 3, 3);
var time = DateTime.UtcNow;
for (int i = 0; i < 15; i++)
{
pmo.Update(new TValue(time.AddSeconds(i), 100.0 + i), true);
}
var afterInf = pmo.Update(new TValue(time.AddSeconds(15), double.PositiveInfinity), true);
Assert.True(double.IsFinite(afterInf.Value));
}
[Fact]
public void Update_BatchNaN_HandlesSafely()
{
var pmo = new Pmo(5, 3, 3);
var time = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
var value = i % 5 == 0 ? double.NaN : 100.0 + i;
var tv = pmo.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 = Pmo.Batch(_gbm, TestTimePeriods, TestSmoothPeriods, TestSignalPeriods);
// Mode 2: Streaming
var streamingPmo = new Pmo(TestTimePeriods, TestSmoothPeriods, TestSignalPeriods);
var streamingResult = new TSeries(DataPoints);
for (int i = 0; i < _gbm.Count; i++)
{
var tv = streamingPmo.Update(new TValue(_gbm[i].Time, _gbm[i].Value), true);
streamingResult.Add(tv, true);
}
// Compare last 50 values (post-warmup region)
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_And_Streaming_ProduceSameResults()
{
// Mode 1: Span-based
Span<double> spanOutput = stackalloc double[DataPoints];
Pmo.Batch(_gbm.Values, spanOutput, TestTimePeriods, TestSmoothPeriods, TestSignalPeriods);
// Mode 2: Streaming
var streamingPmo = new Pmo(TestTimePeriods, TestSmoothPeriods, TestSignalPeriods);
for (int i = 0; i < _gbm.Count; i++)
{
streamingPmo.Update(new TValue(_gbm[i].Time, _gbm[i].Value), true);
}
// Compare last value
Assert.Equal(spanOutput[DataPoints - 1], streamingPmo.Last.Value, 6);
}
#endregion
#region Span API Tests
[Fact]
public void Calculate_Span_ValidatesEmptySource()
{
var ex = Assert.Throws<ArgumentException>(() =>
{
ReadOnlySpan<double> empty = [];
Span<double> output = stackalloc double[1];
Pmo.Batch(empty, output, TestTimePeriods, TestSmoothPeriods, TestSignalPeriods);
});
Assert.Equal("source", ex.ParamName);
}
[Fact]
public void Calculate_Span_ValidatesOutputLength()
{
var ex = Assert.Throws<ArgumentException>(() =>
{
ReadOnlySpan<double> source = stackalloc double[] { 1, 2, 3, 4, 5 };
Span<double> output = stackalloc double[3]; // too short
Pmo.Batch(source, output, TestTimePeriods, TestSmoothPeriods, TestSignalPeriods);
});
Assert.Equal("output", 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];
Pmo.Batch(source, output, 0, TestSmoothPeriods, TestSignalPeriods);
});
Assert.Equal("timePeriods", ex.ParamName);
}
[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;
}
Pmo.Batch(source, output, TestTimePeriods, TestSmoothPeriods, TestSignalPeriods);
Assert.Equal(largeSize, output.Length);
Assert.True(double.IsFinite(output[^1]));
}
#endregion
#region Chainability Tests
[Fact]
public void Pub_FiresOnUpdate()
{
var pmo = new Pmo(TestTimePeriods, TestSmoothPeriods, TestSignalPeriods);
bool eventFired = false;
pmo.Pub += (object? _, in TValueEventArgs e) => eventFired = true;
pmo.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.True(eventFired);
}
[Fact]
public void EventBasedChaining_Works()
{
var source = new TSeries(10);
var pmo = new Pmo(source, 3, 2, 2);
var results = new List<double>();
pmo.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) = Pmo.Calculate(_gbm, TestTimePeriods, TestSmoothPeriods, TestSignalPeriods);
Assert.Equal(DataPoints, results.Count);
Assert.NotNull(indicator);
Assert.True(indicator.IsHot);
}
[Fact]
public void Prime_InitializesState()
{
var pmo = new Pmo(5, 3, 3);
double[] primeData = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109,
110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120];
pmo.Prime(primeData);
Assert.NotEqual(default, pmo.Last);
Assert.True(pmo.IsHot);
}
[Fact]
public void Prime_SameAsSequentialUpdates()
{
var pmo1 = new Pmo(5, 3, 3);
var pmo2 = new Pmo(5, 3, 3);
double[] data = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109,
110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120];
pmo1.Prime(data);
foreach (var value in data)
{
pmo2.Update(new TValue(DateTime.MinValue, value));
}
Assert.Equal(pmo1.Last.Value, pmo2.Last.Value, 10);
}
#endregion
}
+341
View File
@@ -0,0 +1,341 @@
using OoplesFinance.StockIndicators;
using OoplesFinance.StockIndicators.Models;
using Skender.Stock.Indicators;
using Xunit;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for PMO (Price Momentum Oscillator) against external libraries.
/// PMO applies double EMA smoothing to the Rate of Change.
///
/// Skender has GetPmo(). Ooples has CalculatePriceMomentumOscillator().
/// </summary>
public sealed class PmoValidationTests(ITestOutputHelper output) : IDisposable
{
private readonly ValidationTestData _testData = new();
private readonly ITestOutputHelper _output = output;
private bool _disposed;
private const int RocPeriod = 35;
private const int Smooth1Period = 20;
private const int SignalPeriod = 10;
private const double Tolerance = 1e-10;
public void Dispose()
{
Dispose(disposing: true);
}
private void Dispose(bool disposing)
{
if (_disposed) { return; }
_disposed = true;
if (disposing) { _testData?.Dispose(); }
}
#region Skender Validation
[Fact]
public void Pmo_MatchesSkender_Batch()
{
// QuanTAlib PMO
var qResult = Pmo.Batch(_testData.Data, RocPeriod, Smooth1Period, SignalPeriod);
// Skender PMO
var sResult = _testData.SkenderQuotes.GetPmo(RocPeriod, Smooth1Period, SignalPeriod).ToList();
// Compare last 100 records
ValidationHelper.VerifyData(qResult, sResult, (s) => s.Pmo);
_output.WriteLine("PMO Batch validated successfully against Skender");
}
[Fact]
public void Pmo_MatchesSkender_Streaming()
{
// QuanTAlib PMO (streaming)
var pmo = new Pmo(RocPeriod, Smooth1Period, SignalPeriod);
var qResults = new List<double>();
foreach (var item in _testData.Data)
{
qResults.Add(pmo.Update(item).Value);
}
// Skender PMO
var sResult = _testData.SkenderQuotes.GetPmo(RocPeriod, Smooth1Period, SignalPeriod).ToList();
int count = qResults.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
if (sResult[i].Pmo is null) { continue; }
Assert.True(
Math.Abs(qResults[i] - sResult[i].Pmo!.Value) <= ValidationHelper.SkenderTolerance,
$"Mismatch at index {i}: QuanTAlib={qResults[i]:G17}, Skender={sResult[i].Pmo:G17}");
}
_output.WriteLine("PMO Streaming validated successfully against Skender");
}
[Theory]
[InlineData(10, 10, 5)]
[InlineData(35, 20, 10)]
[InlineData(50, 30, 15)]
public void Pmo_MatchesSkender_DifferentPeriods(int rocPeriod, int smooth1, int signal)
{
var qResult = Pmo.Batch(_testData.Data, rocPeriod, smooth1, signal);
var sResult = _testData.SkenderQuotes.GetPmo(rocPeriod, smooth1, signal).ToList();
ValidationHelper.VerifyData(qResult, sResult, (s) => s.Pmo);
}
#endregion
#region Ooples Validation
[Fact]
public void Pmo_MatchesOoples_Batch()
{
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 PMO
var qResult = Pmo.Batch(_testData.Data, RocPeriod, Smooth1Period, SignalPeriod);
// Ooples PMO (DecisionPoint variant uses same algorithm)
var stockData = new StockData(ooplesData);
var oResult = stockData.CalculatePriceMomentumOscillator(
length1: RocPeriod, length2: Smooth1Period, signalLength: SignalPeriod);
var oValues = oResult.OutputValues.Values.First();
int count = qResult.Count;
int warmup = RocPeriod + Smooth1Period + SignalPeriod;
int start = Math.Max(warmup, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
Assert.True(
Math.Abs(qResult[i].Value - oValues[i]) <= ValidationHelper.OoplesTolerance,
$"Mismatch at index {i}: QuanTAlib={qResult[i].Value:G17}, Ooples={oValues[i]:G17}");
}
_output.WriteLine("PMO Batch validated successfully against Ooples");
}
#endregion
#region Self-Consistency
[Fact]
public void Pmo_BatchAndStreaming_AreIdentical()
{
// Batch
var batchResult = Pmo.Batch(_testData.Data, RocPeriod, Smooth1Period, SignalPeriod);
// Streaming
var pmo = new Pmo(RocPeriod, Smooth1Period, SignalPeriod);
var streamingResults = new List<double>();
foreach (var item in _testData.Data)
{
streamingResults.Add(pmo.Update(item).Value);
}
int count = _testData.Data.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
Assert.Equal(batchResult[i].Value, streamingResults[i], Tolerance);
}
_output.WriteLine("PMO Batch vs Streaming consistency validated");
}
[Fact]
public void Pmo_SpanAndBatch_AreIdentical()
{
// Batch TSeries
var batchResult = Pmo.Batch(_testData.Data, RocPeriod, Smooth1Period, SignalPeriod);
// Span
double[] rawData = _testData.RawData.ToArray();
var spanOutput = new double[rawData.Length];
Pmo.Batch(rawData, spanOutput, RocPeriod, Smooth1Period, SignalPeriod);
int count = rawData.Length;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
Assert.Equal(batchResult[i].Value, spanOutput[i], Tolerance);
}
_output.WriteLine("PMO Span vs Batch consistency validated");
}
[Theory]
[InlineData(5, 3, 3)]
[InlineData(10, 10, 5)]
[InlineData(35, 20, 10)]
[InlineData(50, 30, 15)]
public void Pmo_DifferentParameters_BatchStreamingConsistency(int rocPeriod, int smooth1, int smooth2)
{
var batchResult = Pmo.Batch(_testData.Data, rocPeriod, smooth1, smooth2);
var pmo = new Pmo(rocPeriod, smooth1, smooth2);
var streamingResults = new List<double>();
foreach (var item in _testData.Data)
{
streamingResults.Add(pmo.Update(item).Value);
}
int count = _testData.Data.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
Assert.Equal(batchResult[i].Value, streamingResults[i], Tolerance);
}
}
#endregion
#region Known Value Tests
[Fact]
public void Pmo_ConstantInput_ConvergesToZero()
{
// With constant prices, ROC% = 0, so PMO should converge to 0
var pmo = new Pmo(5, 3, 3);
for (int i = 0; i < 100; i++)
{
pmo.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0), true);
}
Assert.True(Math.Abs(pmo.Last.Value) < 1e-6,
$"PMO should converge to 0 for constant input, got {pmo.Last.Value}");
}
[Fact]
public void Pmo_StrongUptrend_ProducesPositive()
{
var pmo = new Pmo(5, 3, 3);
for (int i = 0; i < 50; i++)
{
double price = 100 + i * 5; // Strong uptrend
pmo.Update(new TValue(DateTime.UtcNow.AddSeconds(i), price), true);
}
Assert.True(pmo.Last.Value > 0,
$"PMO should be positive during strong uptrend, got {pmo.Last.Value}");
}
[Fact]
public void Pmo_StrongDowntrend_ProducesNegative()
{
var pmo = new Pmo(5, 3, 3);
for (int i = 0; i < 50; i++)
{
double price = 200 - i * 3; // Strong downtrend
pmo.Update(new TValue(DateTime.UtcNow.AddSeconds(i), price), true);
}
Assert.True(pmo.Last.Value < 0,
$"PMO should be negative during strong downtrend, got {pmo.Last.Value}");
}
[Fact]
public void Pmo_ResetClearsState()
{
var pmo = new Pmo(5, 3, 3);
// Run once
foreach (var item in _testData.Data)
{
pmo.Update(item);
}
var firstRunLast = pmo.Last.Value;
pmo.Reset();
// Run again - should produce identical results
foreach (var item in _testData.Data)
{
pmo.Update(item);
}
Assert.Equal(firstRunLast, pmo.Last.Value, Tolerance);
}
#endregion
#region Behavioral Tests
[Fact]
public void Pmo_RespondsToSmoothingPeriods()
{
// Short smoothing = more responsive = higher amplitude
var pmoFast = new Pmo(5, 3, 2);
var pmoSlow = new Pmo(5, 20, 10);
double sumAbsFast = 0;
double sumAbsSlow = 0;
for (int i = 0; i < _testData.Data.Count; i++)
{
pmoFast.Update(_testData.Data[i]);
pmoSlow.Update(_testData.Data[i]);
if (i >= 50) // After warmup
{
sumAbsFast += Math.Abs(pmoFast.Last.Value);
sumAbsSlow += Math.Abs(pmoSlow.Last.Value);
}
}
Assert.True(sumAbsFast > sumAbsSlow,
$"Fast PMO ({sumAbsFast:F4}) should have higher amplitude than slow PMO ({sumAbsSlow:F4})");
}
[Fact]
public void Pmo_AllOutputsFiniteAfterWarmup()
{
var pmo = new Pmo(RocPeriod, Smooth1Period, SignalPeriod);
foreach (var item in _testData.Data)
{
pmo.Update(item);
Assert.True(double.IsFinite(pmo.Last.Value),
$"PMO output should be finite, got {pmo.Last.Value}");
}
}
[Fact]
public void Pmo_RocPeriodAffectsOutput()
{
var pmo5 = new Pmo(5, 10, 5);
var pmo20 = new Pmo(20, 10, 5);
foreach (var item in _testData.Data)
{
pmo5.Update(item);
pmo20.Update(item);
}
// Different ROC periods should produce different results
Assert.NotEqual(pmo5.Last.Value, pmo20.Last.Value);
}
#endregion
}
+410
View File
@@ -0,0 +1,410 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// Computes the Price Momentum Oscillator (PMO), a double-smoothed rate of change
/// developed by Carl Swenlin (DecisionPoint).
/// </summary>
/// <remarks>
/// DecisionPoint PMO Algorithm:
/// <c>ROC = (Close / Close[1] - 1) × 100</c> (always 1-bar),
/// <c>RocEma = CustomEMA(ROC, timePeriods) × 10</c>,
/// <c>PMO = CustomEMA(RocEma, smoothPeriods)</c>.
///
/// Custom EMA uses alpha = 2/N (not the standard 2/(N+1)), and is seeded with the SMA
/// of the first N values. This matches the original DecisionPoint specification and agrees
/// with both Skender.Stock.Indicators and OoplesFinance implementations.
///
/// PMO oscillates around zero; positive values indicate upward momentum, negative values
/// indicate downward momentum. Crossings of zero or a signal line suggest trend changes.
/// 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="pmo.pine">Reference Pine Script implementation</seealso>
[SkipLocalsInit]
public sealed class Pmo : AbstractBase
{
private const int DefaultTimePeriods = 35;
private const int DefaultSmoothPeriods = 20;
private const int DefaultSignalPeriods = 10;
private readonly int _timePeriods;
private readonly int _smoothPeriods;
private readonly double _alpha1;
private readonly double _alpha2;
[StructLayout(LayoutKind.Auto)]
private record struct State(
double LastValid,
double PrevClose,
double RocEmaRaw,
double Pmo,
double RocSum,
double RocEmaScaledSum,
int RocCount,
int RocEmaCount,
bool HasPrevClose,
bool RocEmaSeeded,
bool PmoSeeded,
int Bars);
private State _state, _p_state;
private ITValuePublisher? _source;
private bool _disposed;
/// <summary>
/// True when the indicator has enough data to produce meaningful PMO values.
/// </summary>
public override bool IsHot => _state.Bars > _timePeriods + _smoothPeriods;
/// <summary>
/// Initializes a new PMO indicator.
/// </summary>
/// <param name="timePeriods">First EMA smoothing period for 1-bar ROC (must be >= 2)</param>
/// <param name="smoothPeriods">Second EMA smoothing period for PMO (must be >= 1)</param>
/// <param name="signalPeriods">Signal line EMA period (reserved for future use, must be >= 1)</param>
public Pmo(int timePeriods = DefaultTimePeriods, int smoothPeriods = DefaultSmoothPeriods, int signalPeriods = DefaultSignalPeriods)
{
if (timePeriods < 2)
{
throw new ArgumentException("Time periods must be >= 2", nameof(timePeriods));
}
if (smoothPeriods < 1)
{
throw new ArgumentException("Smooth periods must be >= 1", nameof(smoothPeriods));
}
if (signalPeriods < 1)
{
throw new ArgumentException("Signal periods must be >= 1", nameof(signalPeriods));
}
_timePeriods = timePeriods;
_smoothPeriods = smoothPeriods;
// DecisionPoint PMO uses custom smoothing: alpha = 2/N (not standard EMA 2/(N+1))
_alpha1 = 2.0 / _timePeriods;
_alpha2 = 2.0 / _smoothPeriods;
Name = $"Pmo({timePeriods},{smoothPeriods},{signalPeriods})";
WarmupPeriod = timePeriods + smoothPeriods;
}
/// <summary>
/// Initializes a new PMO indicator with source for event-based chaining.
/// </summary>
public Pmo(ITValuePublisher source, int timePeriods = DefaultTimePeriods, int smoothPeriods = DefaultSmoothPeriods, int signalPeriods = DefaultSignalPeriods)
: this(timePeriods, smoothPeriods, signalPeriods)
{
_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.LastValid = value;
_state.Bars++;
// Step 1: Compute 1-bar percentage ROC
double roc;
if (!_state.HasPrevClose)
{
roc = 0.0;
_state.HasPrevClose = true;
_state.PrevClose = value;
}
else
{
roc = _state.PrevClose != 0.0
? ((value / _state.PrevClose) - 1.0) * 100.0
: 0.0;
_state.PrevClose = value;
}
// Step 2: First Custom EMA smoothing of 1-bar ROC (SMA-seeded, alpha = 2/timePeriods)
// Skender seeds at index timePeriods (after timePeriods+1 bars), using SMA of timePeriods ROC values [1..timePeriods]
// For streaming: accumulate first _timePeriods ROC values (skip index 0 which has no prev close)
double rocEmaScaled;
if (!_state.RocEmaSeeded)
{
if (_state.Bars == 1)
{
// First bar: ROC = 0, skip for SMA accumulation (Skender starts ROC at index 1)
_state.RocEmaRaw = 0.0;
rocEmaScaled = 0.0;
}
else
{
// Accumulate ROC values for SMA seed
_state.RocSum += roc;
_state.RocCount++;
if (_state.RocCount >= _timePeriods)
{
// SMA seed: average of first _timePeriods ROC values
_state.RocEmaRaw = _state.RocSum / _timePeriods;
_state.RocEmaSeeded = true;
rocEmaScaled = _state.RocEmaRaw * 10.0;
}
else
{
_state.RocEmaRaw = 0.0;
rocEmaScaled = 0.0;
}
}
}
else
{
// Custom EMA: alpha = 2/N
_state.RocEmaRaw = Math.FusedMultiplyAdd(roc - _state.RocEmaRaw, _alpha1, _state.RocEmaRaw);
rocEmaScaled = _state.RocEmaRaw * 10.0;
}
// Step 3: Second Custom EMA smoothing → PMO (SMA-seeded, alpha = 2/smoothPeriods)
double pmoValue;
if (!_state.RocEmaSeeded)
{
// Not enough data for first EMA yet
pmoValue = 0.0;
}
else if (!_state.PmoSeeded)
{
// Accumulate RocEma scaled values for SMA seed
_state.RocEmaScaledSum += rocEmaScaled;
_state.RocEmaCount++;
if (_state.RocEmaCount >= _smoothPeriods)
{
// SMA seed: average of first _smoothPeriods scaled RocEma values
_state.Pmo = _state.RocEmaScaledSum / _smoothPeriods;
_state.PmoSeeded = true;
pmoValue = _state.Pmo;
}
else
{
pmoValue = 0.0;
}
}
else
{
// Custom EMA: alpha = 2/N
_state.Pmo = Math.FusedMultiplyAdd(rocEmaScaled - _state.Pmo, _alpha2, _state.Pmo);
pmoValue = _state.Pmo;
}
Last = new TValue(input.Time, pmoValue);
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 timePeriods = DefaultTimePeriods, int smoothPeriods = DefaultSmoothPeriods, int signalPeriods = DefaultSignalPeriods)
{
var indicator = new Pmo(timePeriods, smoothPeriods, signalPeriods);
return indicator.Update(source);
}
/// <summary>
/// Calculates PMO over a span of values.
/// Zero-allocation method for maximum performance.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int timePeriods = DefaultTimePeriods, int smoothPeriods = DefaultSmoothPeriods, int signalPeriods = DefaultSignalPeriods)
{
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 (timePeriods < 2)
{
throw new ArgumentException("Time periods must be >= 2", nameof(timePeriods));
}
if (smoothPeriods < 1)
{
throw new ArgumentException("Smooth periods must be >= 1", nameof(smoothPeriods));
}
if (signalPeriods < 1)
{
throw new ArgumentException("Signal periods must be >= 1", nameof(signalPeriods));
}
// DecisionPoint PMO custom smoothing: alpha = 2/N
double alpha1 = 2.0 / timePeriods;
double alpha2 = 2.0 / smoothPeriods;
// Step 1: Compute 1-bar ROC for all bars
// Step 2: First CustomEMA(ROC, timePeriods) with SMA seed, then ×10
// Step 3: Second CustomEMA(scaled, smoothPeriods) with SMA seed → PMO
double rocEmaRaw = 0.0;
bool rocEmaSeeded = false;
double rocSum = 0.0;
int rocCount = 0;
double pmo = 0.0;
bool pmoSeeded = false;
double scaledSum = 0.0;
int scaledCount = 0;
for (int i = 0; i < source.Length; i++)
{
// 1-bar ROC
double roc = i > 0 && source[i - 1] != 0.0
? ((source[i] / source[i - 1]) - 1.0) * 100.0
: 0.0;
// First Custom EMA of ROC with SMA seed
double rocEmaScaled;
if (!rocEmaSeeded)
{
if (i == 0)
{
// First bar: no previous close, ROC = 0, skip accumulation
rocEmaScaled = 0.0;
}
else
{
rocSum += roc;
rocCount++;
if (rocCount >= timePeriods)
{
rocEmaRaw = rocSum / timePeriods;
rocEmaSeeded = true;
rocEmaScaled = rocEmaRaw * 10.0;
}
else
{
rocEmaScaled = 0.0;
}
}
}
else
{
rocEmaRaw += alpha1 * (roc - rocEmaRaw);
rocEmaScaled = rocEmaRaw * 10.0;
}
// Second Custom EMA of scaled RocEma with SMA seed → PMO
if (!rocEmaSeeded)
{
output[i] = 0.0;
}
else if (!pmoSeeded)
{
scaledSum += rocEmaScaled;
scaledCount++;
if (scaledCount >= smoothPeriods)
{
pmo = scaledSum / smoothPeriods;
pmoSeeded = true;
output[i] = pmo;
}
else
{
output[i] = 0.0;
}
}
else
{
pmo += alpha2 * (rocEmaScaled - pmo);
output[i] = pmo;
}
}
}
public static (TSeries Results, Pmo Indicator) Calculate(TSeries source, int timePeriods = DefaultTimePeriods, int smoothPeriods = DefaultSmoothPeriods, int signalPeriods = DefaultSignalPeriods)
{
var indicator = new Pmo(timePeriods, smoothPeriods, signalPeriods);
TSeries results = indicator.Update(source);
return (results, indicator);
}
public override void Reset()
{
_state = default;
_p_state = default;
Last = default;
}
protected override void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing && _source != null)
{
_source.Pub -= HandleUpdate;
_source = null;
}
_disposed = true;
}
base.Dispose(disposing);
}
}
+29 -23
View File
@@ -3,40 +3,46 @@
//@version=6
indicator("Price Momentum Oscillator (PMO)", "PMO", overlay=false)
//@function Calculates Price Momentum Oscillator using double-smoothed ROC
//@function Calculates Price Momentum Oscillator (DecisionPoint algorithm)
//@doc https://github.com/mihakralj/pinescript/blob/main/indicators/momentum/pmo.md
//@param src Source series to calculate PMO for
//@param roc_len Lookback period for ROC calculation
//@param smooth1_len First smoothing period
//@param smooth2_len Second smoothing period
//@returns PMO value measuring smoothed momentum
pmo(series float src, simple int roc_len, simple int smooth1_len=20, simple int smooth2_len=10)=>
if roc_len<=0 or smooth1_len<=0 or smooth2_len<=0
runtime.error("Lengths must be greater than 0")
float roc=100*(src-src[math.min(roc_len, bar_index)])/src[math.min(roc_len,bar_index)]
float alpha1=2/(smooth1_len+1)
var float smooth1=na
smooth1:=na(smooth1)?roc:smooth1*(1-alpha1)+roc*alpha1
float alpha2=2/(smooth2_len+1)
var float smooth2=na
smooth2:=na(smooth2)?smooth1:smooth2*(1-alpha2)+smooth1*alpha2
smooth2
//@param time_periods First EMA smoothing period for 1-bar ROC (default 35)
//@param smooth_periods Second EMA smoothing period for PMO (default 20)
//@param signal_periods Signal line EMA period (default 10)
//@returns PMO value measuring double-smoothed momentum
pmo(series float src, simple int time_periods=35, simple int smooth_periods=20, simple int signal_periods=10)=>
if time_periods<2 or smooth_periods<=0 or signal_periods<=0
runtime.error("Periods must be greater than 0 (time_periods >= 2)")
// Step 1: Always 1-bar ROC (percentage)
float roc = bar_index > 0 and not na(src[1]) and src[1] != 0.0 ? (src / src[1] - 1.0) * 100.0 : 0.0
// Step 2: First Custom EMA of ROC (alpha = 2/time_periods), then ×10
float alpha1 = 2.0 / time_periods
var float roc_ema = na
roc_ema := na(roc_ema) ? roc : roc_ema + alpha1 * (roc - roc_ema)
float roc_ema_scaled = roc_ema * 10.0
// Step 3: Second Custom EMA of scaled RocEma (alpha = 2/smooth_periods) → PMO
float alpha2 = 2.0 / smooth_periods
var float pmo_val = na
pmo_val := na(pmo_val) ? roc_ema_scaled : pmo_val + alpha2 * (roc_ema_scaled - pmo_val)
pmo_val
// ---------- Main loop ----------
// Inputs
i_source = input.source(close, "Source")
i_roc_len = input.int(35, "ROC Length", minval=1)
i_smooth1_len = input.int(20, "First Smoothing Length", minval=1)
i_smooth2_len = input.int(10, "Second Smoothing Length", minval=1)
i_signal_len = input.int(10, "Signal Line Length", minval=1)
i_time_periods = input.int(35, "Time Periods (1st EMA)", minval=2)
i_smooth_periods = input.int(20, "Smooth Periods (2nd EMA)", minval=1)
i_signal_periods = input.int(10, "Signal Line Period", minval=1)
// Calculation
pmo_value = pmo(i_source, i_roc_len, i_smooth1_len, i_smooth2_len)
float alpha_signal = 2.0 / (i_signal_len + 1)
pmo_value = pmo(i_source, i_time_periods, i_smooth_periods, i_signal_periods)
// Signal line uses standard EMA: alpha = 2/(N+1)
float alpha_signal = 2.0 / (i_signal_periods + 1)
var float signal_line = na
signal_line := na(signal_line) ? pmo_value : signal_line * (1.0 - alpha_signal) + pmo_value * alpha_signal
signal_line := na(signal_line) ? pmo_value : signal_line + alpha_signal * (pmo_value - signal_line)
// Plot
plot(pmo_value, "PMO", color=color.blue, linewidth=2)
plot(signal_line, "Signal", color=color.red, linewidth=2)
hline(0, "Zero", color=color.gray, linestyle=hline.style_dotted)
+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);
}
}
+10 -2
View File
@@ -54,6 +54,8 @@ public sealed class Prs : AbstractBase
private double _p_e;
private bool _p_isEmaInitialized;
private bool _p_isWarmup;
private double _p_lastValidBase;
private double _p_lastValidComp;
private int _count;
@@ -187,6 +189,8 @@ public sealed class Prs : AbstractBase
_p_e = _e;
_p_isEmaInitialized = _isEmaInitialized;
_p_isWarmup = _isWarmup;
_p_lastValidBase = _lastValidBase;
_p_lastValidComp = _lastValidComp;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
@@ -196,6 +200,8 @@ public sealed class Prs : AbstractBase
_e = _p_e;
_isEmaInitialized = _p_isEmaInitialized;
_isWarmup = _p_isWarmup;
_lastValidBase = _p_lastValidBase;
_lastValidComp = _p_lastValidComp;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
@@ -209,8 +215,8 @@ public sealed class Prs : AbstractBase
if (!_isEmaInitialized)
{
// First value: initialize EMA
_ema = 0;
// First value: initialize EMA with the first ratio
_ema = ratio;
_isEmaInitialized = true;
return ratio;
}
@@ -277,6 +283,8 @@ public sealed class Prs : AbstractBase
_p_e = 1.0;
_p_isEmaInitialized = false;
_p_isWarmup = true;
_p_lastValidBase = 0;
_p_lastValidComp = 0;
}
/// <summary>
+129 -53
View File
@@ -1,29 +1,46 @@
using Skender.Stock.Indicators;
using Xunit;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for ROC (Rate of Change) against Tulip MOM (Momentum).
/// Tulip's MOM calculates absolute change: current - past
/// Validation tests for ROC (Rate of Change) against external libraries.
/// ROC computes absolute change: current - past (same as momentum).
///
/// Tulip's MOM calculates absolute change: current - past.
/// Skender's GetRoc returns RocResult with .Momentum (absolute change).
/// </summary>
public class RocValidationTests
public sealed class RocValidationTests(ITestOutputHelper output) : IDisposable
{
private readonly GBM _gbm = new(sigma: 0.5, mu: 0.05, seed: 60200);
private readonly ValidationTestData _testData = new();
private readonly ITestOutputHelper _output = output;
private bool _disposed;
private const int TestPeriod = 9;
private const int DataPoints = 500;
private const double TulipTolerance = 1e-9;
public void Dispose()
{
Dispose(disposing: true);
}
private void Dispose(bool disposing)
{
if (_disposed) { return; }
_disposed = true;
if (disposing) { _testData?.Dispose(); }
}
#region Tulip MOM Validation
[Fact]
public void Roc_MatchesTulipMom_Batch()
{
var bars = _gbm.Fetch(DataPoints, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var source = bars.Close;
double[] tulipInput = source.Values.ToArray();
double[] tulipInput = _testData.RawData.ToArray();
// Get QuanTAlib ROC result
var quantResult = Roc.Batch(source, TestPeriod);
var quantResult = Roc.Batch(_testData.Data, TestPeriod);
// Calculate Tulip MOM (momentum = current - past)
var momIndicator = Tulip.Indicators.mom;
@@ -35,29 +52,23 @@ public class RocValidationTests
momIndicator.Run(inputs, options, outputs);
var tulipResult = outputs[0];
// Compare (accounting for Tulip's offset due to lookback)
for (int i = 0; i < tulipResult.Length; i++)
{
int qIdx = i + lookback;
Assert.Equal(tulipResult[i], quantResult[qIdx].Value, TulipTolerance);
}
ValidationHelper.VerifyData(quantResult, tulipResult, lookback);
_output.WriteLine("ROC Batch validated successfully against Tulip MOM");
}
[Fact]
public void Roc_MatchesTulipMom_Streaming()
{
var bars = _gbm.Fetch(DataPoints, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var source = bars.Close;
double[] tulipInput = source.Values.ToArray();
double[] tulipInput = _testData.RawData.ToArray();
// Get QuanTAlib ROC result via streaming
var roc = new Roc(TestPeriod);
var streamingResults = new List<double>();
for (int i = 0; i < source.Count; i++)
foreach (var item in _testData.Data)
{
var tv = roc.Update(new TValue(source[i].Time, source[i].Value), true);
streamingResults.Add(tv.Value);
streamingResults.Add(roc.Update(item).Value);
}
// Calculate Tulip MOM
@@ -70,24 +81,19 @@ public class RocValidationTests
momIndicator.Run(inputs, options, outputs);
var tulipResult = outputs[0];
// Compare after warmup
for (int i = 0; i < tulipResult.Length; i++)
{
int qIdx = i + lookback;
Assert.Equal(tulipResult[i], streamingResults[qIdx], TulipTolerance);
}
ValidationHelper.VerifyData(streamingResults, tulipResult, lookback);
_output.WriteLine("ROC Streaming validated successfully against Tulip MOM");
}
[Fact]
public void Roc_MatchesTulipMom_Span()
{
var bars = _gbm.Fetch(DataPoints, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var source = bars.Close;
double[] tulipInput = source.Values.ToArray();
double[] tulipInput = _testData.RawData.ToArray();
// Get QuanTAlib ROC result via span
var quantOutput = new double[DataPoints];
Roc.Batch(source.Values, quantOutput, TestPeriod);
var quantOutput = new double[tulipInput.Length];
Roc.Batch(new ReadOnlySpan<double>(tulipInput), quantOutput, TestPeriod);
// Calculate Tulip MOM
var momIndicator = Tulip.Indicators.mom;
@@ -99,12 +105,9 @@ public class RocValidationTests
momIndicator.Run(inputs, options, outputs);
var tulipResult = outputs[0];
// Compare after warmup
for (int i = 0; i < tulipResult.Length; i++)
{
int qIdx = i + lookback;
Assert.Equal(tulipResult[i], quantOutput[qIdx], TulipTolerance);
}
ValidationHelper.VerifyData(quantOutput, tulipResult, lookback);
_output.WriteLine("ROC Span validated successfully against Tulip MOM");
}
#endregion
@@ -119,11 +122,9 @@ public class RocValidationTests
[InlineData(50)]
public void Roc_MatchesTulipMom_DifferentPeriods(int period)
{
var bars = _gbm.Fetch(DataPoints, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var source = bars.Close;
double[] tulipInput = source.Values.ToArray();
double[] tulipInput = _testData.RawData.ToArray();
var quantResult = Roc.Batch(source, period);
var quantResult = Roc.Batch(_testData.Data, period);
// Calculate Tulip MOM
var momIndicator = Tulip.Indicators.mom;
@@ -135,11 +136,68 @@ public class RocValidationTests
momIndicator.Run(inputs, options, outputs);
var tulipResult = outputs[0];
for (int i = 0; i < tulipResult.Length; i++)
ValidationHelper.VerifyData(quantResult, tulipResult, lookback);
}
#endregion
#region Skender Validation
[Fact]
public void Roc_MatchesSkender_Batch()
{
// QuanTAlib ROC
var qResult = Roc.Batch(_testData.Data, TestPeriod);
// Skender GetRoc returns RocResult with .Momentum (absolute change)
var sResult = _testData.SkenderQuotes.GetRoc(TestPeriod).ToList();
// Compare last 100 records
ValidationHelper.VerifyData(qResult, sResult, (s) => s.Momentum);
_output.WriteLine("ROC Batch validated successfully against Skender (GetRoc.Momentum)");
}
[Fact]
public void Roc_MatchesSkender_Streaming()
{
// QuanTAlib ROC (streaming)
var roc = new Roc(TestPeriod);
var qResults = new List<double>();
foreach (var item in _testData.Data)
{
int qIdx = i + lookback;
Assert.Equal(tulipResult[i], quantResult[qIdx].Value, TulipTolerance);
qResults.Add(roc.Update(item).Value);
}
// Skender GetRoc
var sResult = _testData.SkenderQuotes.GetRoc(TestPeriod).ToList();
int count = qResults.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
if (sResult[i].Momentum is null) { continue; }
Assert.True(
Math.Abs(qResults[i] - sResult[i].Momentum!.Value) <= ValidationHelper.SkenderTolerance,
$"Mismatch at index {i}: QuanTAlib={qResults[i]:G17}, Skender={sResult[i].Momentum:G17}");
}
_output.WriteLine("ROC Streaming validated successfully against Skender (GetRoc.Momentum)");
}
[Theory]
[InlineData(1)]
[InlineData(5)]
[InlineData(20)]
[InlineData(50)]
public void Roc_MatchesSkender_DifferentPeriods(int period)
{
var qResult = Roc.Batch(_testData.Data, period);
var sResult = _testData.SkenderQuotes.GetRoc(period).ToList();
ValidationHelper.VerifyData(qResult, sResult, (s) => s.Momentum);
}
#endregion
@@ -185,11 +243,9 @@ public class RocValidationTests
[Fact]
public void Roc_Period1_MatchesTulipMom()
{
var bars = _gbm.Fetch(DataPoints, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var source = bars.Close;
double[] tulipInput = source.Values.ToArray();
double[] tulipInput = _testData.RawData.ToArray();
var quantResult = Roc.Batch(source, 1);
var quantResult = Roc.Batch(_testData.Data, 1);
// Calculate Tulip MOM with period 1
var momIndicator = Tulip.Indicators.mom;
@@ -201,12 +257,32 @@ public class RocValidationTests
momIndicator.Run(inputs, options, outputs);
var tulipResult = outputs[0];
// Period 1 is single-bar change
for (int i = 0; i < tulipResult.Length; i++)
ValidationHelper.VerifyData(quantResult, tulipResult, lookback);
_output.WriteLine("ROC Period=1 validated against Tulip MOM");
}
[Fact]
public void Batch_MatchesStreaming_IdenticalResults()
{
// Batch
var batchResult = Roc.Batch(_testData.Data, TestPeriod);
// Streaming
var roc = new Roc(TestPeriod);
var streamingResults = new List<double>();
foreach (var item in _testData.Data)
{
int qIdx = i + lookback;
Assert.Equal(tulipResult[i], quantResult[qIdx].Value, TulipTolerance);
streamingResults.Add(roc.Update(item).Value);
}
int count = _testData.Data.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
Assert.Equal(batchResult[i].Value, streamingResults[i], ValidationHelper.DefaultTolerance);
}
_output.WriteLine("ROC Batch vs Streaming consistency validated");
}
#endregion
+216 -165
View File
@@ -1,9 +1,214 @@
using TALib;
using Xunit;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
public class RocpValidationTests
/// <summary>
/// Validation tests for ROCP (Rate of Change Percentage) against external libraries.
/// ROCP = 100 × (Price - Price[N]) / Price[N]
///
/// Note: TALib's RocP returns a decimal fraction (0.05 for 5%), while QuanTAlib returns
/// a percentage (5.0 for 5%). Tests account for this scaling difference.
/// Tulip does not have a direct ROCP indicator.
/// </summary>
public sealed class RocpValidationTests(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();
}
}
private const int TestPeriod = 10;
#region TALib Validation
[Fact]
public void Rocp_MatchesTalib_Batch()
{
double[] tData = _testData.RawData.ToArray();
// QuanTAlib ROCP (batch TSeries)
var rocp = new Rocp(TestPeriod);
var qResult = rocp.Update(_testData.Data);
// TALib RocP (returns decimal fraction)
double[] tOutput = new double[tData.Length];
var retCode = TALib.Functions.RocP<double>(tData, 0..^0, tOutput, out var outRange, TestPeriod);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = TALib.Functions.RocPLookback(TestPeriod);
// Compare: TALib returns decimal, QuanTAlib returns percentage → multiply TALib by 100
int count = qResult.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
var (offset, length) = outRange.GetOffsetAndLength(tOutput.Length);
for (int i = start; i < count; i++)
{
if (i < lookback)
{
continue;
}
int tIndex = i - offset;
if (tIndex < 0 || tIndex >= length)
{
continue;
}
double talibScaled = tOutput[tIndex] * 100.0;
Assert.True(
Math.Abs(qResult[i].Value - talibScaled) <= ValidationHelper.TalibTolerance,
$"Mismatch at index {i}: QuanTAlib={qResult[i].Value:G17}, TALib(×100)={talibScaled:G17}");
}
_output.WriteLine("ROCP Batch validated successfully against TALib");
}
[Fact]
public void Rocp_MatchesTalib_Span()
{
double[] tData = _testData.RawData.ToArray();
// QuanTAlib ROCP (Span)
double[] qOutput = new double[tData.Length];
Rocp.Batch(tData.AsSpan(), qOutput.AsSpan(), TestPeriod);
// TALib RocP
double[] tOutput = new double[tData.Length];
var retCode = TALib.Functions.RocP<double>(tData, 0..^0, tOutput, out var outRange, TestPeriod);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = TALib.Functions.RocPLookback(TestPeriod);
int count = qOutput.Length;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
var (offset, length) = outRange.GetOffsetAndLength(tOutput.Length);
for (int i = start; i < count; i++)
{
if (i < lookback)
{
continue;
}
int tIndex = i - offset;
if (tIndex < 0 || tIndex >= length)
{
continue;
}
double talibScaled = tOutput[tIndex] * 100.0;
Assert.True(
Math.Abs(qOutput[i] - talibScaled) <= ValidationHelper.TalibTolerance,
$"Mismatch at index {i}: QuanTAlib={qOutput[i]:G17}, TALib(×100)={talibScaled:G17}");
}
_output.WriteLine("ROCP Span validated successfully against TALib");
}
[Fact]
public void Rocp_MatchesTalib_Streaming()
{
double[] tData = _testData.RawData.ToArray();
// QuanTAlib ROCP (streaming)
var rocp = new Rocp(TestPeriod);
var qResults = new List<double>();
foreach (var item in _testData.Data)
{
qResults.Add(rocp.Update(item).Value);
}
// TALib RocP
double[] tOutput = new double[tData.Length];
var retCode = TALib.Functions.RocP<double>(tData, 0..^0, tOutput, out var outRange, TestPeriod);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = TALib.Functions.RocPLookback(TestPeriod);
int count = qResults.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
var (offset, length) = outRange.GetOffsetAndLength(tOutput.Length);
for (int i = start; i < count; i++)
{
if (i < lookback)
{
continue;
}
int tIndex = i - offset;
if (tIndex < 0 || tIndex >= length)
{
continue;
}
double talibScaled = tOutput[tIndex] * 100.0;
Assert.True(
Math.Abs(qResults[i] - talibScaled) <= ValidationHelper.TalibTolerance,
$"Mismatch at index {i}: QuanTAlib={qResults[i]:G17}, TALib(×100)={talibScaled:G17}");
}
_output.WriteLine("ROCP Streaming validated successfully against TALib");
}
[Theory]
[InlineData(5)]
[InlineData(14)]
[InlineData(20)]
[InlineData(50)]
public void Rocp_MatchesTalib_DifferentPeriods(int period)
{
double[] tData = _testData.RawData.ToArray();
var rocp = new Rocp(period);
var qResult = rocp.Update(_testData.Data);
double[] tOutput = new double[tData.Length];
var retCode = TALib.Functions.RocP<double>(tData, 0..^0, tOutput, out var outRange, period);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = TALib.Functions.RocPLookback(period);
var (offset, length) = outRange.GetOffsetAndLength(tOutput.Length);
int count = qResult.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
if (i < lookback)
{
continue;
}
int tIndex = i - offset;
if (tIndex < 0 || tIndex >= length)
{
continue;
}
double talibScaled = tOutput[tIndex] * 100.0;
Assert.True(
Math.Abs(qResult[i].Value - talibScaled) <= ValidationHelper.TalibTolerance,
$"Period {period}, index {i}: QuanTAlib={qResult[i].Value:G17}, TALib(×100)={talibScaled:G17}");
}
_output.WriteLine($"ROCP period={period} validated against TALib");
}
#endregion
#region Mathematical Validation
[Fact]
@@ -30,184 +235,30 @@ public class RocpValidationTests
}
}
[Fact]
public void Rocp_FivePercentIncrease_ReturnsFive()
{
var rocp = new Rocp(1);
var time = DateTime.UtcNow;
rocp.Update(new TValue(time, 100.0), true);
var result = rocp.Update(new TValue(time.AddSeconds(1), 105.0), true);
Assert.Equal(5.0, result.Value, 10);
}
[Fact]
public void Rocp_FivePercentDecrease_ReturnsNegativeFive()
{
var rocp = new Rocp(1);
var time = DateTime.UtcNow;
rocp.Update(new TValue(time, 100.0), true);
var result = rocp.Update(new TValue(time.AddSeconds(1), 95.0), true);
Assert.Equal(-5.0, result.Value, 10);
}
#endregion
#region Relationship to ROCR and ROC
[Fact]
public void Rocp_RelationshipToRocr_IsCorrect()
{
// ROCP = (ROCR - 1) * 100
var rocp = new Rocp(2);
var rocr = new Rocr(2);
var time = DateTime.UtcNow;
var values = new double[] { 100, 105, 110, 120, 115 };
for (int i = 0; i < values.Length; i++)
{
rocp.Update(new TValue(time.AddSeconds(i), values[i]), true);
rocr.Update(new TValue(time.AddSeconds(i), values[i]), true);
}
// ROCP = (ROCR - 1) * 100
double expectedFromRocr = (rocr.Last.Value - 1.0) * 100.0;
Assert.Equal(expectedFromRocr, rocp.Last.Value, 10);
}
[Fact]
public void Rocp_RelationshipToRoc_IsCorrect()
{
// ROCP = 100 * ROC / past
var rocp = new Rocp(2);
var roc = new Roc(2);
var time = DateTime.UtcNow;
var values = new double[] { 100, 105, 110, 120, 115 };
for (int i = 0; i < values.Length; i++)
{
rocp.Update(new TValue(time.AddSeconds(i), values[i]), true);
roc.Update(new TValue(time.AddSeconds(i), values[i]), true);
}
// ROCP = 100 * ROC / past
// For last value: past = values[2] = 110
double expectedFromRoc = 100.0 * roc.Last.Value / values[2];
Assert.Equal(expectedFromRoc, rocp.Last.Value, 10);
}
#endregion
#region Edge Cases
[Fact]
public void Rocp_SmallValues_MaintainsPrecision()
{
var rocp = new Rocp(1);
var time = DateTime.UtcNow;
rocp.Update(new TValue(time, 0.0001), true);
var result = rocp.Update(new TValue(time.AddSeconds(1), 0.00015), true);
// 100 * (0.00015 - 0.0001) / 0.0001 = 50%
Assert.Equal(50.0, result.Value, 5);
}
[Fact]
public void Rocp_LargeValues_MaintainsPrecision()
{
var rocp = new Rocp(1);
var time = DateTime.UtcNow;
rocp.Update(new TValue(time, 1_000_000), true);
var result = rocp.Update(new TValue(time.AddSeconds(1), 1_100_000), true);
// 100 * (1_100_000 - 1_000_000) / 1_000_000 = 10%
Assert.Equal(10.0, result.Value, 10);
}
[Fact]
public void Rocp_NegativeValues_HandlesCorrectly()
{
var rocp = new Rocp(1);
var time = DateTime.UtcNow;
rocp.Update(new TValue(time, -100.0), true);
var result = rocp.Update(new TValue(time.AddSeconds(1), -50.0), true);
// 100 * (-50 - (-100)) / (-100) = 100 * 50 / -100 = -50%
Assert.Equal(-50.0, result.Value, 10);
}
[Fact]
public void Rocp_MixedSigns_HandlesCorrectly()
{
var rocp = new Rocp(1);
var time = DateTime.UtcNow;
rocp.Update(new TValue(time, -100.0), true);
var result = rocp.Update(new TValue(time.AddSeconds(1), 100.0), true);
// 100 * (100 - (-100)) / (-100) = 100 * 200 / -100 = -200%
Assert.Equal(-200.0, result.Value, 10);
}
#endregion
#region Batch vs Streaming Consistency
[Fact]
public void Batch_MatchesStreaming_IdenticalResults()
{
var gbm = new GBM(startPrice: 100, mu: 0.0, sigma: 0.5, seed: 42);
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var source = bars.Close;
var source = _testData.Data;
// Streaming
var streamingRocp = new Rocp(5);
var streamingRocp = new Rocp(TestPeriod);
var streamingResults = new List<double>();
for (int i = 0; i < source.Count; i++)
{
var tv = streamingRocp.Update(new TValue(source[i].Time, source[i].Value), true);
streamingResults.Add(tv.Value);
streamingResults.Add(streamingRocp.Update(source[i]).Value);
}
// Batch
var batchResult = Rocp.Batch(source, 5);
var batchRocp = new Rocp(TestPeriod);
var batchResult = batchRocp.Update(source);
for (int i = 0; i < source.Count; i++)
int count = source.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
Assert.Equal(batchResult[i].Value, streamingResults[i], 10);
Assert.Equal(batchResult[i].Value, streamingResults[i], ValidationHelper.DefaultTolerance);
}
}
#endregion
#region TA-Lib Compatibility Notes
[Fact]
public void Rocp_TaLibCompatibility_Conversion()
{
// TA-Lib ROCP returns decimal (0.05 for 5%)
// QuanTAlib ROCP returns percentage (5.0 for 5%)
// Conversion: TaLibRocp = QuanTAlibRocp / 100
var rocp = new Rocp(1);
var time = DateTime.UtcNow;
rocp.Update(new TValue(time, 100.0), true);
var result = rocp.Update(new TValue(time.AddSeconds(1), 105.0), true);
double quantalibRocp = result.Value; // 5.0
double talibEquivalent = quantalibRocp / 100.0; // 0.05
Assert.Equal(5.0, quantalibRocp, 10);
Assert.Equal(0.05, talibEquivalent, 10);
_output.WriteLine("ROCP Batch vs Streaming consistency validated");
}
#endregion
+259 -190
View File
@@ -1,17 +1,263 @@
using TALib;
using Xunit;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
public class RocrValidationTests
/// <summary>
/// Validation tests for ROCR (Rate of Change Ratio) against external libraries.
/// ROCR = Price / Price[N]
///
/// TALib's RocR returns the same ratio. Tulip's rocr returns the same ratio.
/// No scaling adjustment needed.
/// </summary>
public sealed class RocrValidationTests(ITestOutputHelper output) : IDisposable
{
private const double Epsilon = 1e-10;
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(); }
}
private const int TestPeriod = 9;
#region TALib Validation
[Fact]
public void Rocr_MatchesTalib_Batch()
{
double[] tData = _testData.RawData.ToArray();
// QuanTAlib ROCR (batch TSeries)
var rocr = new Rocr(TestPeriod);
var qResult = rocr.Update(_testData.Data);
// TALib RocR
double[] tOutput = new double[tData.Length];
var retCode = TALib.Functions.RocR<double>(tData, 0..^0, tOutput, out var outRange, TestPeriod);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = TALib.Functions.RocRLookback(TestPeriod);
int count = qResult.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
var (offset, length) = outRange.GetOffsetAndLength(tOutput.Length);
for (int i = start; i < count; i++)
{
if (i < lookback) { continue; }
int tIndex = i - offset;
if (tIndex < 0 || tIndex >= length) { continue; }
Assert.True(
Math.Abs(qResult[i].Value - tOutput[tIndex]) <= ValidationHelper.TalibTolerance,
$"Mismatch at index {i}: QuanTAlib={qResult[i].Value:G17}, TALib={tOutput[tIndex]:G17}");
}
_output.WriteLine("ROCR Batch validated successfully against TALib");
}
[Fact]
public void Rocr_MatchesTalib_Span()
{
double[] tData = _testData.RawData.ToArray();
// QuanTAlib ROCR (Span)
double[] qOutput = new double[tData.Length];
Rocr.Batch(tData.AsSpan(), qOutput.AsSpan(), TestPeriod);
// TALib RocR
double[] tOutput = new double[tData.Length];
var retCode = TALib.Functions.RocR<double>(tData, 0..^0, tOutput, out var outRange, TestPeriod);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = TALib.Functions.RocRLookback(TestPeriod);
int count = qOutput.Length;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
var (offset, length) = outRange.GetOffsetAndLength(tOutput.Length);
for (int i = start; i < count; i++)
{
if (i < lookback) { continue; }
int tIndex = i - offset;
if (tIndex < 0 || tIndex >= length) { continue; }
Assert.True(
Math.Abs(qOutput[i] - tOutput[tIndex]) <= ValidationHelper.TalibTolerance,
$"Mismatch at index {i}: QuanTAlib={qOutput[i]:G17}, TALib={tOutput[tIndex]:G17}");
}
_output.WriteLine("ROCR Span validated successfully against TALib");
}
[Fact]
public void Rocr_MatchesTalib_Streaming()
{
double[] tData = _testData.RawData.ToArray();
// QuanTAlib ROCR (streaming)
var rocr = new Rocr(TestPeriod);
var qResults = new List<double>();
foreach (var item in _testData.Data)
{
qResults.Add(rocr.Update(item).Value);
}
// TALib RocR
double[] tOutput = new double[tData.Length];
var retCode = TALib.Functions.RocR<double>(tData, 0..^0, tOutput, out var outRange, TestPeriod);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = TALib.Functions.RocRLookback(TestPeriod);
int count = qResults.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
var (offset, length) = outRange.GetOffsetAndLength(tOutput.Length);
for (int i = start; i < count; i++)
{
if (i < lookback) { continue; }
int tIndex = i - offset;
if (tIndex < 0 || tIndex >= length) { continue; }
Assert.True(
Math.Abs(qResults[i] - tOutput[tIndex]) <= ValidationHelper.TalibTolerance,
$"Mismatch at index {i}: QuanTAlib={qResults[i]:G17}, TALib={tOutput[tIndex]:G17}");
}
_output.WriteLine("ROCR Streaming validated successfully against TALib");
}
[Theory]
[InlineData(5)]
[InlineData(14)]
[InlineData(20)]
[InlineData(50)]
public void Rocr_MatchesTalib_DifferentPeriods(int period)
{
double[] tData = _testData.RawData.ToArray();
var rocr = new Rocr(period);
var qResult = rocr.Update(_testData.Data);
double[] tOutput = new double[tData.Length];
var retCode = TALib.Functions.RocR<double>(tData, 0..^0, tOutput, out var outRange, period);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = TALib.Functions.RocRLookback(period);
var (offset, length) = outRange.GetOffsetAndLength(tOutput.Length);
int count = qResult.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
if (i < lookback) { continue; }
int tIndex = i - offset;
if (tIndex < 0 || tIndex >= length) { continue; }
Assert.True(
Math.Abs(qResult[i].Value - tOutput[tIndex]) <= ValidationHelper.TalibTolerance,
$"Period {period}, index {i}: QuanTAlib={qResult[i].Value:G17}, TALib={tOutput[tIndex]:G17}");
}
_output.WriteLine($"ROCR period={period} validated against TALib");
}
#endregion
#region Tulip Validation
[Fact]
public void Rocr_MatchesTulip_Batch()
{
double[] tData = _testData.RawData.ToArray();
// QuanTAlib ROCR
var rocr = new Rocr(TestPeriod);
var qResult = rocr.Update(_testData.Data);
// Tulip rocr
var rocrIndicator = Tulip.Indicators.rocr;
double[][] inputs = [tData];
double[] options = [TestPeriod];
int lookback = rocrIndicator.Start(options);
double[][] outputs = [new double[tData.Length - lookback]];
rocrIndicator.Run(inputs, options, outputs);
double[] tulipResult = outputs[0];
// Compare after lookback
ValidationHelper.VerifyData(qResult, tulipResult, lookback);
_output.WriteLine("ROCR Batch validated successfully against Tulip");
}
[Fact]
public void Rocr_MatchesTulip_Streaming()
{
double[] tData = _testData.RawData.ToArray();
// QuanTAlib ROCR (streaming)
var rocr = new Rocr(TestPeriod);
var qResults = new List<double>();
foreach (var item in _testData.Data)
{
qResults.Add(rocr.Update(item).Value);
}
// Tulip rocr
var rocrIndicator = Tulip.Indicators.rocr;
double[][] inputs = [tData];
double[] options = [TestPeriod];
int lookback = rocrIndicator.Start(options);
double[][] outputs = [new double[tData.Length - lookback]];
rocrIndicator.Run(inputs, options, outputs);
double[] tulipResult = outputs[0];
ValidationHelper.VerifyData(qResults, tulipResult, lookback);
_output.WriteLine("ROCR Streaming validated successfully against Tulip");
}
[Theory]
[InlineData(5)]
[InlineData(14)]
[InlineData(20)]
public void Rocr_MatchesTulip_DifferentPeriods(int period)
{
double[] tData = _testData.RawData.ToArray();
var rocr = new Rocr(period);
var qResult = rocr.Update(_testData.Data);
var rocrIndicator = Tulip.Indicators.rocr;
double[][] inputs = [tData];
double[] options = [period];
int lookback = rocrIndicator.Start(options);
double[][] outputs = [new double[tData.Length - lookback]];
rocrIndicator.Run(inputs, options, outputs);
double[] tulipResult = outputs[0];
ValidationHelper.VerifyData(qResult, tulipResult, lookback);
}
#endregion
#region Mathematical Validation
[Fact]
public void Rocr_ManualCalculation_MatchesExpected()
{
// Manual test: ROCR = current / past
var rocr = new Rocr(3);
var time = DateTime.UtcNow;
@@ -23,217 +269,40 @@ public class RocrValidationTests
if (i >= 3)
{
// After warmup, should return ratio
double expected = values[i] / values[i - 3];
Assert.Equal(expected, result.Value, 10);
}
else
{
// During warmup, should return 1.0
Assert.Equal(1.0, result.Value, 10);
}
}
}
[Fact]
public void Rocr_TenPercentIncrease_Returns1Point1()
{
var rocr = new Rocr(1); // 1-period lookback
var time = DateTime.UtcNow;
rocr.Update(new TValue(time, 100.0), true);
var result = rocr.Update(new TValue(time.AddSeconds(1), 110.0), true);
// 110 / 100 = 1.10
Assert.Equal(1.10, result.Value, 10);
}
[Fact]
public void Rocr_TenPercentDecrease_Returns0Point9()
{
var rocr = new Rocr(1);
var time = DateTime.UtcNow;
rocr.Update(new TValue(time, 100.0), true);
var result = rocr.Update(new TValue(time.AddSeconds(1), 90.0), true);
// 90 / 100 = 0.90
Assert.Equal(0.90, result.Value, 10);
}
[Fact]
public void Rocr_ConversionToRocp_IsCorrect()
{
// ROCP = (ROCR - 1) * 100
var rocr = new Rocr(1);
var time = DateTime.UtcNow;
rocr.Update(new TValue(time, 100.0), true);
var result = rocr.Update(new TValue(time.AddSeconds(1), 115.0), true);
double rocp = (result.Value - 1.0) * 100.0;
// 115/100 = 1.15, ROCP = (1.15 - 1) * 100 = 15%
Assert.Equal(15.0, rocp, 10);
}
[Fact]
public void Rocr_ConversionFromChange_IsCorrect()
{
// CHANGE = (current - past) / past = ROCR - 1
var rocr = new Rocr(1);
var time = DateTime.UtcNow;
rocr.Update(new TValue(time, 100.0), true);
var result = rocr.Update(new TValue(time.AddSeconds(1), 125.0), true);
double change = result.Value - 1.0;
// 125/100 = 1.25, CHANGE = 0.25 = 25% increase
Assert.Equal(0.25, change, 10);
}
#endregion
#region Relationship to ROC
[Fact]
public void Rocr_RelationshipToRoc_IsCorrect()
{
// ROC = current - past
// ROCR = current / past
// If we know ROC and past, we can verify: ROCR = (ROC + past) / past = 1 + ROC/past
var rocr = new Rocr(2);
var roc = new Roc(2);
var time = DateTime.UtcNow;
var values = new double[] { 100, 105, 110, 120, 115 };
for (int i = 0; i < values.Length; i++)
{
rocr.Update(new TValue(time.AddSeconds(i), values[i]), true);
roc.Update(new TValue(time.AddSeconds(i), values[i]), true);
}
// For last value: ROCR = current/past, ROC = current - past
// past = values[3] = 110, current = values[4] = 115
// ROCR = 115/110, ROC = 115 - 110 = 5
// Relationship: ROCR = (past + ROC) / past = 1 + ROC/past
double expectedRelationship = 1.0 + roc.Last.Value / values[2];
Assert.Equal(expectedRelationship, rocr.Last.Value, 10);
}
#endregion
#region Compounding Property
[Fact]
public void Rocr_Compounding_MultiplyForTotalChange()
{
// ROCR values can be multiplied to get total change
var rocr = new Rocr(1);
var time = DateTime.UtcNow;
var values = new double[] { 100, 110, 121, 133.1 }; // ~10% increase each period
double compound = 1.0;
for (int i = 0; i < values.Length; i++)
{
var result = rocr.Update(new TValue(time.AddSeconds(i), values[i]), true);
if (i > 0)
{
compound *= result.Value;
}
}
// Total change from 100 to 133.1 = 1.331
double expectedTotal = values[^1] / values[0];
Assert.Equal(expectedTotal, compound, 5);
}
#endregion
#region Edge Cases
[Fact]
public void Rocr_SmallValues_MaintainsPrecision()
{
var rocr = new Rocr(1);
var time = DateTime.UtcNow;
rocr.Update(new TValue(time, 0.0001), true);
var result = rocr.Update(new TValue(time.AddSeconds(1), 0.00015), true);
// 0.00015 / 0.0001 = 1.5
Assert.Equal(1.5, result.Value, 5);
}
[Fact]
public void Rocr_LargeValues_MaintainsPrecision()
{
var rocr = new Rocr(1);
var time = DateTime.UtcNow;
rocr.Update(new TValue(time, 1_000_000), true);
var result = rocr.Update(new TValue(time.AddSeconds(1), 1_100_000), true);
// 1_100_000 / 1_000_000 = 1.1
Assert.Equal(1.1, result.Value, 10);
}
[Fact]
public void Rocr_NegativeValues_HandlesCorrectly()
{
// Negative values can occur in spreads, basis, etc.
var rocr = new Rocr(1);
var time = DateTime.UtcNow;
rocr.Update(new TValue(time, -100.0), true);
var result = rocr.Update(new TValue(time.AddSeconds(1), -50.0), true);
// -50 / -100 = 0.5
Assert.Equal(0.5, result.Value, 10);
}
[Fact]
public void Rocr_MixedSigns_HandlesCorrectly()
{
var rocr = new Rocr(1);
var time = DateTime.UtcNow;
rocr.Update(new TValue(time, -100.0), true);
var result = rocr.Update(new TValue(time.AddSeconds(1), 100.0), true);
// 100 / -100 = -1.0
Assert.Equal(-1.0, result.Value, 10);
}
#endregion
#region Batch vs Streaming Consistency
[Fact]
public void Batch_MatchesStreaming_IdenticalResults()
{
var gbm = new GBM(startPrice: 100, mu: 0.0, sigma: 0.5, seed: 42);
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var source = bars.Close;
var source = _testData.Data;
// Streaming
var streamingRocr = new Rocr(5);
var streamingRocr = new Rocr(TestPeriod);
var streamingResults = new List<double>();
for (int i = 0; i < source.Count; i++)
{
var tv = streamingRocr.Update(new TValue(source[i].Time, source[i].Value), true);
streamingResults.Add(tv.Value);
streamingResults.Add(streamingRocr.Update(source[i]).Value);
}
// Batch
var batchResult = Rocr.Batch(source, 5);
var batchRocr = new Rocr(TestPeriod);
var batchResult = batchRocr.Update(source);
for (int i = 0; i < source.Count; i++)
int count = source.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
Assert.Equal(batchResult[i].Value, streamingResults[i], 10);
Assert.Equal(batchResult[i].Value, streamingResults[i], ValidationHelper.DefaultTolerance);
}
_output.WriteLine("ROCR Batch vs Streaming consistency validated");
}
#endregion
+184 -273
View File
@@ -1,77 +1,196 @@
using OoplesFinance.StockIndicators;
using OoplesFinance.StockIndicators.Models;
using Skender.Stock.Indicators;
using Xunit;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
public class TsiValidationTests
/// <summary>
/// Validation tests for TSI (True Strength Index) against external libraries.
/// TSI = 100 × EMA(EMA(momentum, long), short) / EMA(EMA(|momentum|, long), short)
/// Signal line: EMA(TSI, signalPeriod)
///
/// Skender has GetTsi(). Ooples has CalculateTrueStrengthIndex().
/// </summary>
public sealed class TsiValidationTests(ITestOutputHelper output) : IDisposable
{
private const double Epsilon = 1e-6;
private readonly ValidationTestData _testData = new();
private readonly ITestOutputHelper _output = output;
private bool _disposed;
// ==================== FORMULA VALIDATION ====================
[Fact]
public void Formula_ConstantMomentumApproachesExtreme()
private const int LongPeriod = 25;
private const int ShortPeriod = 13;
private const int SignalPeriod = 13;
public void Dispose()
{
// TSI = 100 × doubleSmoothedMom / doubleSmoothedAbsMom
// With constant positive momentum, TSI approaches +100
var tsi = new Tsi(3, 2, 2);
Dispose(disposing: true);
}
// Strong consistent uptrend
for (int i = 0; i < 50; i++)
private void Dispose(bool disposing)
{
if (_disposed) { return; }
_disposed = true;
if (disposing) { _testData?.Dispose(); }
}
#region Skender Validation
[Fact]
public void Tsi_MatchesSkender_Batch()
{
// QuanTAlib TSI
var qResult = Tsi.Batch(_testData.Data, LongPeriod, ShortPeriod, SignalPeriod);
// Skender TSI
var sResult = _testData.SkenderQuotes.GetTsi(LongPeriod, ShortPeriod, SignalPeriod).ToList();
// Compare last 100 records (skip warmup)
ValidationHelper.VerifyData(qResult, sResult, (s) => s.Tsi);
_output.WriteLine("TSI Batch validated successfully against Skender");
}
[Fact]
public void Tsi_MatchesSkender_Streaming()
{
// QuanTAlib TSI (streaming)
var tsi = new Tsi(LongPeriod, ShortPeriod, SignalPeriod);
var qResults = new List<double>();
foreach (var item in _testData.Data)
{
tsi.Update(new TValue(DateTime.Now.AddMinutes(i), 100.0 + i * 2));
qResults.Add(tsi.Update(item).Value);
}
// Skender TSI
var sResult = _testData.SkenderQuotes.GetTsi(LongPeriod, ShortPeriod, SignalPeriod).ToList();
int count = qResults.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
if (sResult[i].Tsi is null) { continue; }
Assert.True(
Math.Abs(qResults[i] - sResult[i].Tsi!.Value) <= ValidationHelper.SkenderTolerance,
$"Mismatch at index {i}: QuanTAlib={qResults[i]:G17}, Skender={sResult[i].Tsi:G17}");
}
_output.WriteLine("TSI Streaming validated successfully against Skender");
}
[Theory]
[InlineData(13, 7, 7)]
[InlineData(25, 13, 13)]
[InlineData(40, 20, 10)]
public void Tsi_MatchesSkender_DifferentPeriods(int longPeriod, int shortPeriod, int signalPeriod)
{
var qResult = Tsi.Batch(_testData.Data, longPeriod, shortPeriod, signalPeriod);
var sResult = _testData.SkenderQuotes.GetTsi(longPeriod, shortPeriod, signalPeriod).ToList();
ValidationHelper.VerifyData(qResult, sResult, (s) => s.Tsi);
}
#endregion
#region Ooples Validation
[Fact]
public void Tsi_MatchesOoples_Batch()
{
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 TSI
var qResult = Tsi.Batch(_testData.Data, LongPeriod, ShortPeriod, SignalPeriod);
// Ooples TSI
var stockData = new StockData(ooplesData);
var oResult = stockData.CalculateTrueStrengthIndex(length1: LongPeriod, length2: ShortPeriod, signalLength: SignalPeriod);
var oValues = oResult.OutputValues.Values.First();
int count = qResult.Count;
int warmup = LongPeriod + ShortPeriod + SignalPeriod;
int start = Math.Max(warmup, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
Assert.True(
Math.Abs(qResult[i].Value - oValues[i]) <= ValidationHelper.OoplesTolerance,
$"Mismatch at index {i}: QuanTAlib={qResult[i].Value:G17}, Ooples={oValues[i]:G17}");
}
_output.WriteLine("TSI Batch validated successfully against Ooples");
}
#endregion
#region Formula Validation
[Fact]
public void Tsi_ConstantPositiveMomentum_ApproachesPositive100()
{
var tsi = new Tsi(3, 2, 2);
for (int i = 0; i < 50; i++)
{
tsi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0 + i * 2));
}
// Should be close to +100
Assert.True(tsi.Last.Value > 95.0, $"Expected TSI > 95, got {tsi.Last.Value}");
}
[Fact]
public void Formula_ConstantNegativeMomentumApproachesNegativeExtreme()
public void Tsi_ConstantNegativeMomentum_ApproachesNegative100()
{
var tsi = new Tsi(3, 2, 2);
// Strong consistent downtrend
for (int i = 0; i < 50; i++)
{
tsi.Update(new TValue(DateTime.Now.AddMinutes(i), 200.0 - i * 2));
tsi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 200.0 - i * 2));
}
// Should be close to -100
Assert.True(tsi.Last.Value < -95.0, $"Expected TSI < -95, got {tsi.Last.Value}");
}
[Fact]
public void Formula_ZeroMomentumGivesZeroTsi()
public void Tsi_NoChange_ApproachesZero()
{
var tsi = new Tsi(3, 2, 2);
// No price change
for (int i = 0; i < 20; i++)
{
tsi.Update(new TValue(DateTime.Now.AddMinutes(i), 100.0));
tsi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0));
}
Assert.True(Math.Abs(tsi.Last.Value) < 1.0, $"Expected TSI ≈ 0, got {tsi.Last.Value}");
}
// ==================== SIGNAL LINE VALIDATION ====================
[Fact]
public void Signal_LagsMainTsi()
public void Tsi_SignalLagsMainLine()
{
var tsi = new Tsi(5, 3, 3);
var tsiValues = new List<double>();
var signalValues = new List<double>();
// Create a trend change
for (int i = 0; i < 20; i++)
{
double price = i < 10 ? 100.0 + i * 2 : 120.0 - (i - 10) * 2;
tsi.Update(new TValue(DateTime.Now.AddMinutes(i), price));
tsi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price));
tsiValues.Add(tsi.Last.Value);
signalValues.Add(tsi.Signal);
}
// Signal should lag TSI - when TSI turns, signal follows
// Check that standard deviation of differences is not zero (they're different)
// Signal should lag TSI
var diff = tsiValues.Zip(signalValues, (t, s) => t - s).ToList();
double avgDiff = diff.Average();
double variance = diff.Average(d => (d - avgDiff) * (d - avgDiff));
@@ -80,286 +199,78 @@ public class TsiValidationTests
}
[Fact]
public void Signal_ConvergesInSteadyTrend()
public void Tsi_RangeIsBounded()
{
var tsi = new Tsi(5, 3, 3);
var tsi = new Tsi(LongPeriod, ShortPeriod, SignalPeriod);
const double epsilon = 1e-10;
// Consistent uptrend
for (int i = 0; i < 100; i++)
foreach (var item in _testData.Data)
{
tsi.Update(new TValue(DateTime.Now.AddMinutes(i), 100.0 + i));
tsi.Update(item);
Assert.True(tsi.Last.Value >= -100 - epsilon && tsi.Last.Value <= 100 + epsilon,
$"TSI value {tsi.Last.Value} out of range [-100, 100]");
}
// In steady trend, TSI and Signal should converge
double diff = Math.Abs(tsi.Last.Value - tsi.Signal);
Assert.True(diff < 5.0, $"Expected TSI and Signal to converge, diff = {diff}");
}
// ==================== WARMUP VALIDATION ====================
[Fact]
public void Warmup_GradualConvergence()
{
var tsi = new Tsi(5, 3, 3);
var values = new List<double>();
#endregion
// Rising prices
for (int i = 0; i < 30; i++)
{
tsi.Update(new TValue(DateTime.Now.AddMinutes(i), 100.0 + i));
values.Add(tsi.Last.Value);
}
// Values should stabilize as warmup completes
var lastFive = values.Skip(values.Count - 5).ToList();
var firstFive = values.Skip(5).Take(5).ToList();
double lastRange = lastFive.Max() - lastFive.Min();
double firstRange = firstFive.Max() - firstFive.Min();
// Later values should be more stable (smaller range)
Assert.True(lastRange <= firstRange || lastRange < 5.0);
}
#region Consistency Validation
[Fact]
public void Warmup_Period_MatchesExpected()
public void Batch_MatchesStreaming_IdenticalResults()
{
var tsi = new Tsi(25, 13, 13);
Assert.Equal(25 + 13 + 13, tsi.WarmupPeriod);
}
// TSI uses triple EMA smoothing (long EMA → short EMA → signal EMA),
// so batch vs streaming modes diverge during warmup due to different
// initialization paths. Compare only well-converged tail values.
const double convergenceTolerance = 1e-6;
// ==================== EDGE CASE VALIDATION ====================
[Fact]
public void EdgeCase_AlternatingPrices()
{
var tsi = new Tsi(5, 3, 3);
// Batch
var batchResult = Tsi.Batch(_testData.Data, LongPeriod, ShortPeriod, SignalPeriod);
// Alternating prices (no net trend)
for (int i = 0; i < 30; i++)
{
double price = 100.0 + (i % 2 == 0 ? 5 : -5);
tsi.Update(new TValue(DateTime.Now.AddMinutes(i), price));
}
// Should oscillate around zero
Assert.True(Math.Abs(tsi.Last.Value) < 50.0);
}
[Fact]
public void EdgeCase_LargePriceSpike()
{
var tsi = new Tsi(5, 3, 3);
// Stable prices
for (int i = 0; i < 15; i++)
{
tsi.Update(new TValue(DateTime.Now.AddMinutes(i), 100.0));
}
// Large spike
tsi.Update(new TValue(DateTime.Now.AddMinutes(16), 150.0));
Assert.True(!double.IsNaN(tsi.Last.Value));
Assert.True(!double.IsInfinity(tsi.Last.Value));
Assert.True(tsi.Last.Value > 0); // Should be positive after spike up
}
[Fact]
public void EdgeCase_VerySmallPeriods()
{
var tsi = new Tsi(1, 1, 1);
for (int i = 0; i < 20; i++)
{
tsi.Update(new TValue(DateTime.Now.AddMinutes(i), 100.0 + i));
}
Assert.True(!double.IsNaN(tsi.Last.Value));
Assert.True(tsi.Last.Value >= -100 && tsi.Last.Value <= 100);
}
[Fact]
public void EdgeCase_VeryLargePeriods()
{
var tsi = new Tsi(100, 50, 25);
for (int i = 0; i < 300; i++)
{
tsi.Update(new TValue(DateTime.Now.AddMinutes(i), 100.0 + i * 0.1));
}
Assert.True(!double.IsNaN(tsi.Last.Value));
Assert.True(tsi.Last.Value >= -100 && tsi.Last.Value <= 100);
}
// ==================== COMPARISON VALIDATION ====================
[Fact]
public void Comparison_BatchVsStreaming()
{
var source = new TSeries();
var random = new Random(42);
for (int i = 0; i < 100; i++)
{
source.Add(new TValue(DateTime.Now.AddMinutes(i), 100.0 + random.NextDouble() * 30));
}
// Batch calculation
var batchResult = Tsi.Batch(source, 10, 5, 5);
// Streaming calculation
var tsi = new Tsi(10, 5, 5);
// Streaming
var tsi = new Tsi(LongPeriod, ShortPeriod, SignalPeriod);
var streamingResults = new List<double>();
foreach (var value in source)
foreach (var value in _testData.Data)
{
streamingResults.Add(tsi.Update(value).Value);
}
// Compare (skip warmup period)
for (int i = 30; i < source.Count; i++)
// Skip early warmup region where initialization paths diverge
int count = _testData.Data.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
Assert.Equal(batchResult.Values[i], streamingResults[i], 5);
Assert.True(
Math.Abs(batchResult.Values[i] - streamingResults[i]) <= convergenceTolerance,
$"Mismatch at index {i}: Batch={batchResult.Values[i]:G17}, Streaming={streamingResults[i]:G17}");
}
_output.WriteLine("TSI Batch vs Streaming consistency validated");
}
[Fact]
public void Comparison_DifferentParametersSameTrend()
public void Tsi_ResetProducesIdenticalResults()
{
var tsi1 = new Tsi(25, 13, 13); // Default
var tsi2 = new Tsi(13, 7, 7); // Shorter
var tsi = new Tsi(LongPeriod, ShortPeriod, SignalPeriod);
for (int i = 0; i < 100; i++)
// First run
foreach (var item in _testData.Data)
{
var tval = new TValue(DateTime.Now.AddMinutes(i), 100.0 + i);
tsi1.Update(tval);
tsi2.Update(tval);
tsi.Update(item);
}
// Both should be positive for uptrend
Assert.True(tsi1.Last.Value > 0);
Assert.True(tsi2.Last.Value > 0);
// Shorter period should react faster (closer to +100)
Assert.True(tsi2.Last.Value >= tsi1.Last.Value - 10);
}
// ==================== STATE VALIDATION ====================
[Fact]
public void State_ResetClearsAll()
{
var tsi = new Tsi(5, 3, 3);
for (int i = 0; i < 20; i++)
{
tsi.Update(new TValue(DateTime.Now.AddMinutes(i), 100.0 + i));
}
Assert.True(tsi.IsHot);
Assert.NotEqual(default, tsi.Last);
var firstValue = tsi.Last.Value;
var firstSignal = tsi.Signal;
tsi.Reset();
Assert.False(tsi.IsHot);
Assert.Equal(default, tsi.Last);
Assert.Equal(0, tsi.Signal);
}
[Fact]
public void State_BarCorrectionMaintainsConsistency()
{
var tsi = new Tsi(5, 3, 3);
// Build up history with gradual price increases
for (int i = 0; i < 15; i++)
// Second run
foreach (var item in _testData.Data)
{
tsi.Update(new TValue(DateTime.Now.AddMinutes(i), 100.0 + i));
tsi.Update(item);
}
_ = tsi.Last.Value; // Capture stable value (unused, for state verification)
// Large spike - very different from trend
tsi.Update(new TValue(DateTime.Now.AddMinutes(16), 250.0), isNew: true);
var spike = tsi.Last.Value;
// Correct bar to much smaller value (below trend continuation)
tsi.Update(new TValue(DateTime.Now.AddMinutes(16), 110.0), isNew: false);
var corrected = tsi.Last.Value;
// Spike should have higher TSI than corrected (more positive momentum)
Assert.True(spike > corrected,
$"Spike ({spike:F4}) should be greater than corrected ({corrected:F4})");
Assert.Equal(firstValue, tsi.Last.Value, 1e-10);
Assert.Equal(firstSignal, tsi.Signal, 1e-10);
}
// ==================== MATHEMATICAL PROPERTIES ====================
[Fact]
public void Math_SymmetryWithInvertedPrices()
{
var tsi1 = new Tsi(5, 3, 3);
var tsi2 = new Tsi(5, 3, 3);
// Feed reversed prices
for (int i = 0; i < 30; i++)
{
tsi1.Update(new TValue(DateTime.Now.AddMinutes(i), 100.0 + i));
tsi2.Update(new TValue(DateTime.Now.AddMinutes(i), 129.0 - i));
}
// Should be approximately symmetric (opposite signs)
Assert.True(Math.Abs(tsi1.Last.Value + tsi2.Last.Value) < 5.0,
$"Expected symmetry: TSI1={tsi1.Last.Value}, TSI2={tsi2.Last.Value}");
}
[Fact]
public void Math_RatioPreservesScale()
{
var tsi1 = new Tsi(5, 3, 3);
var tsi2 = new Tsi(5, 3, 3);
// Same relative changes, different absolute scale
for (int i = 0; i < 30; i++)
{
tsi1.Update(new TValue(DateTime.Now.AddMinutes(i), 100.0 + i));
tsi2.Update(new TValue(DateTime.Now.AddMinutes(i), 1000.0 + i * 10));
}
// TSI should be similar (same percentage changes)
Assert.True(Math.Abs(tsi1.Last.Value - tsi2.Last.Value) < 5.0,
$"TSI should be scale-independent: TSI1={tsi1.Last.Value}, TSI2={tsi2.Last.Value}");
}
// ==================== CROSS-VALIDATION ====================
[Fact]
public void CrossValidation_ConsistentWithPineFormula()
{
// TSI = 100 × EMA(EMA(mom, long), short) / EMA(EMA(|mom|, long), short)
var tsi = new Tsi(5, 3, 3);
double[] prices = [100, 102, 101, 104, 103, 106, 105, 108, 107, 110, 109, 112, 111, 114, 113, 116];
foreach (var price in prices)
{
tsi.Update(new TValue(DateTime.Now, price));
}
// Result should be bounded and reasonable
Assert.True(tsi.Last.Value >= -100 && tsi.Last.Value <= 100);
// With alternating up-down pattern, should be positive overall (slight uptrend)
Assert.True(tsi.Last.Value > 0);
}
[Fact]
public void CrossValidation_MatchesManualDoubleSmoothing()
{
var tsi = new Tsi(3, 2, 2);
// Simple test data
double[] prices = [100, 102, 104, 106, 108, 110, 112, 114, 116, 118, 120];
foreach (var price in prices)
{
tsi.Update(new TValue(DateTime.Now, price));
}
// Consistent +2 momentum = 100% TSI (or close to it)
Assert.True(tsi.Last.Value > 90, $"Expected TSI > 90 for constant momentum, got {tsi.Last.Value}");
}
#endregion
}