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