Files
QuanTAlib/lib/momentum/cmo/Cmo.Validation.Tests.cs
T

260 lines
7.6 KiB
C#
Raw Normal View History

using Skender.Stock.Indicators;
2026-02-10 21:33:16 -08:00
using Xunit;
using Xunit.Abstractions;
2026-02-10 21:33:16 -08:00
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for CMO (Chande Momentum Oscillator) against external libraries.
2026-02-10 21:33:16 -08:00
/// 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.
2026-02-10 21:33:16 -08:00
/// </summary>
public sealed class CmoValidationTests(ITestOutputHelper output) : IDisposable
2026-02-10 21:33:16 -08:00
{
private readonly ValidationTestData _testData = new();
private readonly ITestOutputHelper _output = output;
private bool _disposed;
2026-02-10 21:33:16 -08:00
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
2026-02-10 21:33:16 -08:00
[Fact]
public void Cmo_MatchesTulip_Batch()
2026-02-10 21:33:16 -08:00
{
double[] tData = _testData.RawData.ToArray();
2026-02-10 21:33:16 -08:00
double[] qOutput = new double[tData.Length];
Cmo.Batch(tData.AsSpan(), qOutput.AsSpan(), TestPeriod);
2026-02-10 21:33:16 -08:00
// Tulip cmo
2026-02-10 21:33:16 -08:00
var cmoIndicator = Tulip.Indicators.cmo;
double[][] inputs = [tData];
double[] options = [TestPeriod];
2026-02-10 21:33:16 -08:00
int lookback = cmoIndicator.Start(options);
double[][] outputs = [new double[tData.Length - lookback]];
2026-02-10 21:33:16 -08:00
cmoIndicator.Run(inputs, options, outputs);
double[] tulipResult = outputs[0];
2026-02-10 21:33:16 -08:00
ValidationHelper.VerifyData(qOutput, tulipResult, lookback);
2026-02-10 21:33:16 -08:00
_output.WriteLine("CMO Batch validated successfully against Tulip");
2026-02-10 21:33:16 -08:00
}
[Fact]
public void Cmo_MatchesTulip_Streaming()
2026-02-10 21:33:16 -08:00
{
double[] tData = _testData.RawData.ToArray();
// QuanTAlib CMO (streaming)
var cmo = new Cmo(TestPeriod);
var qResults = new List<double>();
foreach (var item in _testData.Data)
2026-02-10 21:33:16 -08:00
{
qResults.Add(cmo.Update(item).Value);
2026-02-10 21:33:16 -08:00
}
// 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);
2026-02-10 21:33:16 -08:00
var cmoIndicator = Tulip.Indicators.cmo;
double[][] inputs = [tData];
2026-02-10 21:33:16 -08:00
double[] options = [period];
int lookback = cmoIndicator.Start(options);
double[][] outputs = [new double[tData.Length - lookback]];
2026-02-10 21:33:16 -08:00
cmoIndicator.Run(inputs, options, outputs);
double[] tulipResult = outputs[0];
2026-02-10 21:33:16 -08:00
ValidationHelper.VerifyData(qOutput, tulipResult, lookback);
}
2026-02-10 21:33:16 -08:00
#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");
2026-02-10 21:33:16 -08:00
}
[Fact]
public void Cmo_MatchesSkender_Streaming()
2026-02-10 21:33:16 -08:00
{
// QuanTAlib CMO (streaming)
var cmo = new Cmo(TestPeriod);
var qResults = new List<double>();
foreach (var item in _testData.Data)
2026-02-10 21:33:16 -08:00
{
qResults.Add(cmo.Update(item).Value);
2026-02-10 21:33:16 -08:00
}
// Skender CMO
var sResult = _testData.SkenderQuotes.GetCmo(TestPeriod).ToList();
2026-02-10 21:33:16 -08:00
int count = qResults.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
2026-02-10 21:33:16 -08:00
for (int i = start; i < count; i++)
2026-02-10 21:33:16 -08:00
{
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}");
2026-02-10 21:33:16 -08:00
}
_output.WriteLine("CMO Streaming validated successfully against Skender");
2026-02-10 21:33:16 -08:00
}
[Theory]
[InlineData(5)]
[InlineData(10)]
[InlineData(20)]
[InlineData(30)]
public void Cmo_MatchesSkender_DifferentPeriods(int period)
2026-02-10 21:33:16 -08:00
{
var qResult = Cmo.Batch(_testData.Data, period);
2026-02-10 21:33:16 -08:00
var sResult = _testData.SkenderQuotes.GetCmo(period).ToList();
2026-02-10 21:33:16 -08:00
ValidationHelper.VerifyData(qResult, sResult, (s) => s.Cmo);
2026-02-10 21:33:16 -08:00
}
#endregion
#region Mathematical Validation
2026-02-10 21:33:16 -08:00
[Fact]
public void Cmo_AllUpMoves_Returns100()
2026-02-10 21:33:16 -08:00
{
double[] prices = [100, 101, 102, 103, 104, 105];
int period = 5;
double[] result = new double[prices.Length];
Cmo.Batch(prices, result, period);
2026-02-10 21:33:16 -08:00
// After 5 periods: SumUp = 5, SumDown = 0 → CMO = 100
Assert.Equal(100.0, result[5], 1e-9);
2026-02-10 21:33:16 -08:00
}
[Fact]
public void Cmo_AllDownMoves_ReturnsNegative100()
2026-02-10 21:33:16 -08:00
{
double[] prices = [105, 104, 103, 102, 101, 100];
int period = 5;
double[] result = new double[prices.Length];
Cmo.Batch(prices, result, period);
2026-02-10 21:33:16 -08:00
// After 5 periods: SumUp = 0, SumDown = 5 → CMO = -100
Assert.Equal(-100.0, result[5], 1e-9);
2026-02-10 21:33:16 -08:00
}
[Fact]
public void Cmo_EqualMoves_ReturnsZero()
2026-02-10 21:33:16 -08:00
{
double[] prices = [100, 102, 100, 102, 100]; // up 2, down 2, up 2, down 2
int period = 4;
double[] result = new double[prices.Length];
Cmo.Batch(prices, result, period);
2026-02-10 21:33:16 -08:00
// SumUp = 4, SumDown = 4 → CMO = 0
Assert.Equal(0.0, result[4], 1e-9);
2026-02-10 21:33:16 -08:00
}
[Fact]
public void Cmo_RangeIsBounded()
{
double[] tData = _testData.RawData.ToArray();
2026-02-10 21:33:16 -08:00
double[] result = new double[tData.Length];
Cmo.Batch(tData.AsSpan(), result.AsSpan(), TestPeriod);
2026-02-10 21:33:16 -08:00
// All values after warmup should be in [-100, 100]
for (int i = TestPeriod; i < result.Length; i++)
2026-02-10 21:33:16 -08:00
{
Assert.True(result[i] >= -100.0 && result[i] <= 100.0,
$"CMO at index {i} = {result[i]} is out of range [-100, 100]");
2026-02-10 21:33:16 -08:00
}
}
[Fact]
public void Batch_MatchesStreaming_IdenticalResults()
2026-02-10 21:33:16 -08:00
{
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++)
2026-02-10 21:33:16 -08:00
{
streamingResults[i] = cmo.Update(new TValue(DateTime.UtcNow.Ticks + i, tData[i])).Value;
2026-02-10 21:33:16 -08:00
}
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");
2026-02-10 21:33:16 -08:00
}
#endregion
2026-02-10 21:33:16 -08:00
}