2026-02-26 22:02:52 -08:00
|
|
|
|
using OoplesFinance.StockIndicators;
|
|
|
|
|
|
using OoplesFinance.StockIndicators.Models;
|
2026-02-11 14:46:56 -08:00
|
|
|
|
using Skender.Stock.Indicators;
|
2026-02-26 22:02:52 -08:00
|
|
|
|
using TALib;
|
2026-02-10 21:33:16 -08:00
|
|
|
|
using Xunit;
|
2026-02-11 14:46:56 -08:00
|
|
|
|
using Xunit.Abstractions;
|
2026-02-10 21:33:16 -08:00
|
|
|
|
|
|
|
|
|
|
namespace QuanTAlib.Tests;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
2026-02-11 14:46:56 -08:00
|
|
|
|
/// Validation tests for CMO (Chande Momentum Oscillator) against external libraries.
|
2026-02-10 21:33:16 -08:00
|
|
|
|
/// CMO = 100 × (SumUp - SumDown) / (SumUp + SumDown)
|
2026-02-11 14:46:56 -08:00
|
|
|
|
///
|
|
|
|
|
|
/// 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-26 22:02:52 -08:00
|
|
|
|
/// The TALib test below validates structural properties only — not numeric equality.
|
2026-02-10 21:33:16 -08:00
|
|
|
|
/// </summary>
|
2026-02-11 14:46:56 -08:00
|
|
|
|
public sealed class CmoValidationTests(ITestOutputHelper output) : IDisposable
|
2026-02-10 21:33:16 -08:00
|
|
|
|
{
|
2026-02-11 14:46:56 -08:00
|
|
|
|
private readonly ValidationTestData _testData = new();
|
|
|
|
|
|
private readonly ITestOutputHelper _output = output;
|
|
|
|
|
|
private bool _disposed;
|
2026-02-10 21:33:16 -08:00
|
|
|
|
|
2026-02-11 14:46:56 -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]
|
2026-02-11 14:46:56 -08:00
|
|
|
|
public void Cmo_MatchesTulip_Batch()
|
2026-02-10 21:33:16 -08:00
|
|
|
|
{
|
2026-02-11 14:46:56 -08:00
|
|
|
|
double[] tData = _testData.RawData.ToArray();
|
2026-02-10 21:33:16 -08:00
|
|
|
|
|
2026-02-11 14:46:56 -08:00
|
|
|
|
double[] qOutput = new double[tData.Length];
|
|
|
|
|
|
Cmo.Batch(tData.AsSpan(), qOutput.AsSpan(), TestPeriod);
|
2026-02-10 21:33:16 -08:00
|
|
|
|
|
2026-02-11 14:46:56 -08:00
|
|
|
|
// Tulip cmo
|
2026-02-10 21:33:16 -08:00
|
|
|
|
var cmoIndicator = Tulip.Indicators.cmo;
|
2026-02-11 14:46:56 -08:00
|
|
|
|
double[][] inputs = [tData];
|
|
|
|
|
|
double[] options = [TestPeriod];
|
2026-02-10 21:33:16 -08:00
|
|
|
|
int lookback = cmoIndicator.Start(options);
|
2026-02-11 14:46:56 -08:00
|
|
|
|
double[][] outputs = [new double[tData.Length - lookback]];
|
|
|
|
|
|
|
2026-02-10 21:33:16 -08:00
|
|
|
|
cmoIndicator.Run(inputs, options, outputs);
|
2026-02-11 14:46:56 -08:00
|
|
|
|
double[] tulipResult = outputs[0];
|
2026-02-10 21:33:16 -08:00
|
|
|
|
|
2026-02-11 14:46:56 -08:00
|
|
|
|
ValidationHelper.VerifyData(qOutput, tulipResult, lookback);
|
2026-02-10 21:33:16 -08:00
|
|
|
|
|
2026-02-11 14:46:56 -08:00
|
|
|
|
_output.WriteLine("CMO Batch validated successfully against Tulip");
|
2026-02-10 21:33:16 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
[Fact]
|
2026-02-11 14:46:56 -08:00
|
|
|
|
public void Cmo_MatchesTulip_Streaming()
|
2026-02-10 21:33:16 -08:00
|
|
|
|
{
|
2026-02-11 14:46:56 -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
|
|
|
|
{
|
2026-02-11 14:46:56 -08:00
|
|
|
|
qResults.Add(cmo.Update(item).Value);
|
2026-02-10 21:33:16 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 14:46:56 -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;
|
2026-02-11 14:46:56 -08:00
|
|
|
|
double[][] inputs = [tData];
|
2026-02-10 21:33:16 -08:00
|
|
|
|
double[] options = [period];
|
|
|
|
|
|
int lookback = cmoIndicator.Start(options);
|
2026-02-11 14:46:56 -08:00
|
|
|
|
double[][] outputs = [new double[tData.Length - lookback]];
|
|
|
|
|
|
|
2026-02-10 21:33:16 -08:00
|
|
|
|
cmoIndicator.Run(inputs, options, outputs);
|
2026-02-11 14:46:56 -08:00
|
|
|
|
double[] tulipResult = outputs[0];
|
2026-02-10 21:33:16 -08:00
|
|
|
|
|
2026-02-11 14:46:56 -08:00
|
|
|
|
ValidationHelper.VerifyData(qOutput, tulipResult, lookback);
|
|
|
|
|
|
}
|
2026-02-10 21:33:16 -08:00
|
|
|
|
|
2026-02-11 14:46:56 -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]
|
2026-02-11 14:46:56 -08:00
|
|
|
|
public void Cmo_MatchesSkender_Streaming()
|
2026-02-10 21:33:16 -08:00
|
|
|
|
{
|
2026-02-11 14:46:56 -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
|
|
|
|
{
|
2026-02-11 14:46:56 -08:00
|
|
|
|
qResults.Add(cmo.Update(item).Value);
|
2026-02-10 21:33:16 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 14:46:56 -08:00
|
|
|
|
// Skender CMO
|
|
|
|
|
|
var sResult = _testData.SkenderQuotes.GetCmo(TestPeriod).ToList();
|
2026-02-10 21:33:16 -08:00
|
|
|
|
|
2026-02-11 14:46:56 -08:00
|
|
|
|
int count = qResults.Count;
|
|
|
|
|
|
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
|
2026-02-10 21:33:16 -08:00
|
|
|
|
|
2026-02-11 14:46:56 -08:00
|
|
|
|
for (int i = start; i < count; i++)
|
2026-02-10 21:33:16 -08:00
|
|
|
|
{
|
2026-02-11 14:46:56 -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
|
|
|
|
}
|
2026-02-11 14:46:56 -08:00
|
|
|
|
|
|
|
|
|
|
_output.WriteLine("CMO Streaming validated successfully against Skender");
|
2026-02-10 21:33:16 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 14:46:56 -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
|
|
|
|
{
|
2026-02-11 14:46:56 -08:00
|
|
|
|
var qResult = Cmo.Batch(_testData.Data, period);
|
2026-02-10 21:33:16 -08:00
|
|
|
|
|
2026-02-11 14:46:56 -08:00
|
|
|
|
var sResult = _testData.SkenderQuotes.GetCmo(period).ToList();
|
2026-02-10 21:33:16 -08:00
|
|
|
|
|
2026-02-11 14:46:56 -08:00
|
|
|
|
ValidationHelper.VerifyData(qResult, sResult, (s) => s.Cmo);
|
2026-02-10 21:33:16 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 14:46:56 -08:00
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
|
|
#region Mathematical Validation
|
2026-02-10 21:33:16 -08:00
|
|
|
|
|
|
|
|
|
|
[Fact]
|
2026-02-11 14:46:56 -08:00
|
|
|
|
public void Cmo_AllUpMoves_Returns100()
|
2026-02-10 21:33:16 -08:00
|
|
|
|
{
|
|
|
|
|
|
double[] prices = [100, 101, 102, 103, 104, 105];
|
|
|
|
|
|
int period = 5;
|
|
|
|
|
|
|
2026-02-11 14:46:56 -08:00
|
|
|
|
double[] result = new double[prices.Length];
|
|
|
|
|
|
Cmo.Batch(prices, result, period);
|
2026-02-10 21:33:16 -08:00
|
|
|
|
|
2026-02-11 14:46:56 -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]
|
2026-02-11 14:46:56 -08:00
|
|
|
|
public void Cmo_AllDownMoves_ReturnsNegative100()
|
2026-02-10 21:33:16 -08:00
|
|
|
|
{
|
|
|
|
|
|
double[] prices = [105, 104, 103, 102, 101, 100];
|
|
|
|
|
|
int period = 5;
|
|
|
|
|
|
|
2026-02-11 14:46:56 -08:00
|
|
|
|
double[] result = new double[prices.Length];
|
|
|
|
|
|
Cmo.Batch(prices, result, period);
|
2026-02-10 21:33:16 -08:00
|
|
|
|
|
2026-02-11 14:46:56 -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]
|
2026-02-11 14:46:56 -08:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-02-11 14:46:56 -08:00
|
|
|
|
double[] result = new double[prices.Length];
|
|
|
|
|
|
Cmo.Batch(prices, result, period);
|
2026-02-10 21:33:16 -08:00
|
|
|
|
|
2026-02-11 14:46:56 -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()
|
|
|
|
|
|
{
|
2026-02-11 14:46:56 -08:00
|
|
|
|
double[] tData = _testData.RawData.ToArray();
|
2026-02-10 21:33:16 -08:00
|
|
|
|
|
2026-02-11 14:46:56 -08:00
|
|
|
|
double[] result = new double[tData.Length];
|
|
|
|
|
|
Cmo.Batch(tData.AsSpan(), result.AsSpan(), TestPeriod);
|
2026-02-10 21:33:16 -08:00
|
|
|
|
|
2026-02-11 14:46:56 -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
|
|
|
|
{
|
2026-02-11 14:46:56 -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]
|
2026-02-11 14:46:56 -08:00
|
|
|
|
public void Batch_MatchesStreaming_IdenticalResults()
|
2026-02-10 21:33:16 -08:00
|
|
|
|
{
|
2026-02-11 14:46:56 -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
|
|
|
|
{
|
2026-02-11 14:46:56 -08:00
|
|
|
|
streamingResults[i] = cmo.Update(new TValue(DateTime.UtcNow.Ticks + i, tData[i])).Value;
|
2026-02-10 21:33:16 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 14:46:56 -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
|
|
|
|
}
|
2026-02-11 14:46:56 -08:00
|
|
|
|
|
|
|
|
|
|
#endregion
|
2026-02-26 22:02:52 -08:00
|
|
|
|
|
|
|
|
|
|
#region TALib Structural Validation
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// TALib CMO uses Wilder's smoothed averaging (EMA-based) rather than the
|
|
|
|
|
|
/// standard simple-sum formula used by QuanTAlib, Tulip, and Skender.
|
|
|
|
|
|
/// Numeric equality cannot be expected. This test verifies:
|
|
|
|
|
|
/// 1. TALib runs without error and produces a valid output range.
|
|
|
|
|
|
/// 2. Both implementations produce bounded CMO values in [-100, +100].
|
|
|
|
|
|
/// 3. Direction agreement: sign of QuanTAlib vs TALib is the same for
|
|
|
|
|
|
/// well-converged (post-warmup) bars (>80% agreement expected).
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
[Fact]
|
|
|
|
|
|
public void Cmo_TaLib_StructuralValidation()
|
|
|
|
|
|
{
|
|
|
|
|
|
double[] tData = _testData.RawData.ToArray();
|
|
|
|
|
|
|
|
|
|
|
|
// --- TALib CMO ---
|
|
|
|
|
|
double[] taOut = new double[tData.Length];
|
|
|
|
|
|
var retCode = Functions.Cmo<double>(tData, 0..^0, taOut, out var outRange, TestPeriod);
|
2026-02-27 12:50:05 -08:00
|
|
|
|
Assert.Equal(TALib.Core.RetCode.Success, retCode);
|
2026-02-26 22:02:52 -08:00
|
|
|
|
|
|
|
|
|
|
var (taOffset, taLength) = outRange.GetOffsetAndLength(taOut.Length);
|
|
|
|
|
|
Assert.True(taLength > 0, "TALib produced no output");
|
|
|
|
|
|
|
|
|
|
|
|
// --- QuanTAlib CMO ---
|
|
|
|
|
|
double[] qOut = new double[tData.Length];
|
|
|
|
|
|
Cmo.Batch(tData.AsSpan(), qOut.AsSpan(), TestPeriod);
|
|
|
|
|
|
|
|
|
|
|
|
// Both outputs should be bounded in [-100, +100]
|
|
|
|
|
|
for (int j = 0; j < taLength; j++)
|
|
|
|
|
|
{
|
|
|
|
|
|
int qi = j + taOffset;
|
|
|
|
|
|
Assert.True(taOut[j] >= -100.0 && taOut[j] <= 100.0,
|
|
|
|
|
|
$"TALib CMO[{j}]={taOut[j]:F4} outside [-100,+100]");
|
|
|
|
|
|
if (double.IsFinite(qOut[qi]))
|
|
|
|
|
|
{
|
|
|
|
|
|
Assert.True(qOut[qi] >= -100.0 && qOut[qi] <= 100.0,
|
|
|
|
|
|
$"QuanTAlib CMO[{qi}]={qOut[qi]:F4} outside [-100,+100]");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Sign agreement (directional concordance) — expect >70% after full convergence
|
|
|
|
|
|
// TALib Wilder-CMO converges after ~3× period bars
|
|
|
|
|
|
int compareStart = TestPeriod * 3;
|
|
|
|
|
|
int agreementCount = 0;
|
|
|
|
|
|
int compareCount = 0;
|
|
|
|
|
|
|
|
|
|
|
|
for (int j = 0; j < taLength; j++)
|
|
|
|
|
|
{
|
|
|
|
|
|
int qi = j + taOffset;
|
|
|
|
|
|
if (qi < compareStart || !double.IsFinite(qOut[qi])) { continue; }
|
|
|
|
|
|
|
|
|
|
|
|
compareCount++;
|
|
|
|
|
|
if (Math.Sign(taOut[j]) == Math.Sign(qOut[qi])) { agreementCount++; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (compareCount > 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
double agreementRate = (double)agreementCount / compareCount;
|
|
|
|
|
|
Assert.True(agreementRate >= 0.70,
|
|
|
|
|
|
$"TALib/QuanTAlib CMO sign agreement {agreementRate:P1} < 70% ({agreementCount}/{compareCount})");
|
|
|
|
|
|
_output.WriteLine($"CMO TALib structural: {taLength} output bars, sign agreement={agreementRate:P1}");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
|
public void Cmo_TaLib_Lookback_Matches_Expected()
|
|
|
|
|
|
{
|
|
|
|
|
|
int lookback = Functions.CmoLookback(TestPeriod);
|
|
|
|
|
|
// TALib CMO lookback = period (Wilder's period)
|
|
|
|
|
|
Assert.True(lookback > 0, $"TALib CMO lookback={lookback} should be positive");
|
|
|
|
|
|
_output.WriteLine($"TALib CMO lookback for period={TestPeriod}: {lookback}");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
|
|
#region Ooples Validation
|
|
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
|
public void Cmo_Matches_Ooples_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();
|
|
|
|
|
|
|
|
|
|
|
|
var stockData = new StockData(ooplesData);
|
|
|
|
|
|
var oResult = stockData.CalculateChandeMomentumOscillator(length: TestPeriod);
|
|
|
|
|
|
var oValues = oResult.OutputValues["Cmo"];
|
|
|
|
|
|
|
|
|
|
|
|
var qResult = Cmo.Batch(_testData.Data, TestPeriod);
|
|
|
|
|
|
|
|
|
|
|
|
ValidationHelper.VerifyData(qResult, oValues, (s) => s, tolerance: ValidationHelper.OoplesTolerance);
|
|
|
|
|
|
_output.WriteLine("CMO Batch validated against Ooples");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#endregion
|
2026-02-10 21:33:16 -08:00
|
|
|
|
}
|