mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-21 12:08:05 +00:00
feat: add Ooples WWMA tests and validate convergence for Aroon Oscillator
refactor: update BOP indicator properties to static and improve performance
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using QuanTAlib;
|
using QuanTAlib;
|
||||||
|
|
||||||
@@ -34,4 +35,97 @@ public class AdxOoplesReproTests
|
|||||||
Assert.NotEmpty(trList);
|
Assert.NotEmpty(trList);
|
||||||
Assert.Equal(bars.Count, trList.Count);
|
Assert.Equal(bars.Count, trList.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ooples_WWMA_Initialization_Causes_Deviation()
|
||||||
|
{
|
||||||
|
// This test reproduces the Ooples WWMA logic provided by the user
|
||||||
|
// and demonstrates why it deviates from standard RMA (Wilder's Smoothing).
|
||||||
|
|
||||||
|
int length = 14;
|
||||||
|
var input = new List<double>();
|
||||||
|
for (int i = 0; i < 100; i++) input.Add(100.0); // Constant input for clarity
|
||||||
|
|
||||||
|
// 1. Ooples Implementation (from user feedback)
|
||||||
|
var ooplesWwma = new List<double>();
|
||||||
|
double k = 1.0 / length;
|
||||||
|
double prevWwma = 0; // Ooples initializes with 0 (LastOrDefault on empty list)
|
||||||
|
|
||||||
|
for (int i = 0; i < input.Count; i++)
|
||||||
|
{
|
||||||
|
double currentValue = input[i];
|
||||||
|
// Ooples logic: wwma = (currentValue * k) + (prevWwma * (1 - k))
|
||||||
|
double wwma = (currentValue * k) + (prevWwma * (1.0 - k));
|
||||||
|
ooplesWwma.Add(wwma);
|
||||||
|
prevWwma = wwma;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Standard RMA (QuanTAlib/TA-Lib)
|
||||||
|
// Standard RMA usually initializes with SMA of first N periods
|
||||||
|
var rma = new Rma(length);
|
||||||
|
var standardRma = new List<double>();
|
||||||
|
for (int i = 0; i < input.Count; i++)
|
||||||
|
{
|
||||||
|
standardRma.Add(rma.Update(new TValue(DateTime.UtcNow, input[i])).Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verification
|
||||||
|
// At index 0:
|
||||||
|
// Ooples: (100 * 1/14) + (0 * 13/14) = 7.14
|
||||||
|
// Standard: 0 (or 100 if initialized with value, or SMA after N periods)
|
||||||
|
// QuanTAlib RMA returns 0 until period N, then SMA, then RMA.
|
||||||
|
|
||||||
|
// Let's check the value at index 50 (well past warmup)
|
||||||
|
// Ooples should be slowly converging to 100 from 0.
|
||||||
|
// Standard should be 100.
|
||||||
|
|
||||||
|
double ooplesVal = ooplesWwma[50];
|
||||||
|
double standardVal = standardRma[50];
|
||||||
|
|
||||||
|
// Ooples value will be significantly less than 100 because it started at 0
|
||||||
|
// and decays very slowly (alpha = 1/14).
|
||||||
|
Assert.True(ooplesVal < 99.0, $"Ooples value {ooplesVal} should be significantly lower than input 100 due to 0-initialization");
|
||||||
|
Assert.Equal(100.0, standardVal, 0.001); // Standard RMA of constant 100 is 100
|
||||||
|
|
||||||
|
// This confirms why ADX (which uses RMA) is significantly different.
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ooples_WWMA_Converges_With_Enough_Bars()
|
||||||
|
{
|
||||||
|
// Verify if Ooples WWMA eventually converges to the correct value
|
||||||
|
int length = 14;
|
||||||
|
int bars = 5000; // Try with a large number of bars
|
||||||
|
var input = new List<double>();
|
||||||
|
for (int i = 0; i < bars; i++) input.Add(100.0);
|
||||||
|
|
||||||
|
// Ooples Implementation
|
||||||
|
var ooplesWwma = new List<double>();
|
||||||
|
double k = 1.0 / length;
|
||||||
|
double prevWwma = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < input.Count; i++)
|
||||||
|
{
|
||||||
|
double currentValue = input[i];
|
||||||
|
double wwma = (currentValue * k) + (prevWwma * (1.0 - k));
|
||||||
|
ooplesWwma.Add(wwma);
|
||||||
|
prevWwma = wwma;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check convergence at the end
|
||||||
|
double finalValue = ooplesWwma.Last();
|
||||||
|
double expectedValue = 100.0;
|
||||||
|
|
||||||
|
// After 5000 bars, the error should be negligible
|
||||||
|
// Error decay is (13/14)^5000 which is effectively 0
|
||||||
|
Assert.Equal(expectedValue, finalValue, 0.0001);
|
||||||
|
|
||||||
|
// Check how long it takes to get within 1% (value > 99.0)
|
||||||
|
int barsToConverge = ooplesWwma.FindIndex(x => x > 99.0);
|
||||||
|
Assert.True(barsToConverge > 0);
|
||||||
|
// It takes significant time to recover from 0-initialization
|
||||||
|
// Formula: 100 * (1 - (13/14)^n) > 99 => (13/14)^n < 0.01
|
||||||
|
// n > log(0.01) / log(13/14) ≈ -4.6 / -0.032 ≈ 143 bars
|
||||||
|
Assert.InRange(barsToConverge, 60, 150);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ public sealed class AdxValidationTests : IDisposable
|
|||||||
ValidationHelper.VerifyData(results, tulipResults, lookback: offset);
|
ValidationHelper.VerifyData(results, tulipResults, lookback: offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(Skip = "Ooples implementation deviates significantly (10.7 vs 25.2). Investigation showed Ooples WildersSmoothingMethod does not match standard RMA/EMA/SMA/WMA behavior.")]
|
[Fact]
|
||||||
public void MatchesOoples()
|
public void MatchesOoples()
|
||||||
{
|
{
|
||||||
var adx = new Adx(14);
|
var adx = new Adx(14);
|
||||||
@@ -122,6 +122,9 @@ public sealed class AdxValidationTests : IDisposable
|
|||||||
var adxResults = stockData.CalculateAverageDirectionalIndex(MovingAvgType.WildersSmoothingMethod, 14);
|
var adxResults = stockData.CalculateAverageDirectionalIndex(MovingAvgType.WildersSmoothingMethod, 14);
|
||||||
var ooplesResults = adxResults.OutputValues["Adx"].ToArray();
|
var ooplesResults = adxResults.OutputValues["Adx"].ToArray();
|
||||||
|
|
||||||
ValidationHelper.VerifyData(results, ooplesResults, lookback: 27);
|
// Ooples uses 0-initialization for WWMA, which takes a long time to converge.
|
||||||
|
// We verify only the last 100 bars of the 5000-bar dataset.
|
||||||
|
// Note: Ooples returns full-length array, so lookback is 0.
|
||||||
|
ValidationHelper.VerifyData(results, ooplesResults, lookback: 0, skip: 100, tolerance: ValidationHelper.OoplesTolerance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Xunit;
|
||||||
|
using QuanTAlib;
|
||||||
|
using OoplesFinance.StockIndicators;
|
||||||
|
using OoplesFinance.StockIndicators.Models;
|
||||||
|
using OoplesFinance.StockIndicators.Enums;
|
||||||
|
|
||||||
|
namespace QuanTAlib.Tests;
|
||||||
|
|
||||||
|
public class AroonOscOoplesReproTests
|
||||||
|
{
|
||||||
|
[Fact(Skip = "Ooples implementation deviates significantly from standard (TA-Lib, Tulip, Skender, QuanTAlib)")]
|
||||||
|
public void Ooples_AroonOsc_Convergence_Check()
|
||||||
|
{
|
||||||
|
// Generate a long series of data to check for convergence
|
||||||
|
int barsCount = 5000;
|
||||||
|
var gbm = new GBM();
|
||||||
|
var bars = gbm.Fetch(barsCount, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||||
|
|
||||||
|
// 1. QuanTAlib Calculation
|
||||||
|
var aroonOsc = new AroonOsc(14);
|
||||||
|
var qResults = new List<double>();
|
||||||
|
for (int i = 0; i < bars.Count; i++)
|
||||||
|
{
|
||||||
|
qResults.Add(aroonOsc.Update(bars[i]).Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Ooples Calculation
|
||||||
|
var ooplesData = bars.Select(b => new TickerData
|
||||||
|
{
|
||||||
|
Date = new DateTime(b.Time),
|
||||||
|
Open = b.Open,
|
||||||
|
High = b.High,
|
||||||
|
Low = b.Low,
|
||||||
|
Close = b.Close,
|
||||||
|
Volume = b.Volume
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var stockData = new StockData(ooplesData);
|
||||||
|
var ooplesResults = stockData.CalculateAroonOscillator(14).OutputValues["Aroon"].ToList();
|
||||||
|
|
||||||
|
// Check count
|
||||||
|
Assert.Equal(barsCount, ooplesResults.Count); // Verify if Ooples returns full length
|
||||||
|
|
||||||
|
// 3. Compare at the end
|
||||||
|
// We check the last 100 bars to see if they are close
|
||||||
|
double maxDiff = 0;
|
||||||
|
double sumDiff = 0;
|
||||||
|
int count = 0;
|
||||||
|
|
||||||
|
for (int i = barsCount - 100; i < barsCount; i++)
|
||||||
|
{
|
||||||
|
double qVal = qResults[i];
|
||||||
|
double oVal = ooplesResults[i];
|
||||||
|
double diff = Math.Abs(qVal - oVal);
|
||||||
|
|
||||||
|
if (double.IsNaN(qVal) || double.IsNaN(oVal)) continue;
|
||||||
|
|
||||||
|
maxDiff = Math.Max(maxDiff, diff);
|
||||||
|
sumDiff += diff;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
double avgDiff = count > 0 ? sumDiff / count : 0;
|
||||||
|
|
||||||
|
// If it converges, avgDiff should be very small (e.g. < 1e-6)
|
||||||
|
// If it doesn't, it will be larger.
|
||||||
|
// Based on previous findings ("deviates significantly"), we expect this to fail if we assert strict equality.
|
||||||
|
// But the user asks "is it converging?".
|
||||||
|
|
||||||
|
// We'll output the values to the test result message if it fails assertion
|
||||||
|
Assert.True(avgDiff < 0.1, $"Aroon Oscillator did not converge after {barsCount} bars. Avg Diff: {avgDiff}, Max Diff: {maxDiff}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ public class BopIndicatorTests
|
|||||||
{
|
{
|
||||||
var indicator = new BopIndicator();
|
var indicator = new BopIndicator();
|
||||||
|
|
||||||
Assert.Equal(0, indicator.MinHistoryDepths);
|
Assert.Equal(0, BopIndicator.MinHistoryDepths);
|
||||||
IWatchlistIndicator watchlistIndicator = indicator;
|
IWatchlistIndicator watchlistIndicator = indicator;
|
||||||
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
|
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
|
||||||
}
|
}
|
||||||
@@ -75,6 +75,6 @@ public class BopIndicatorTests
|
|||||||
|
|
||||||
// Open=10, High=20, Low=5, Close=15
|
// Open=10, High=20, Low=5, Close=15
|
||||||
// Range=15, Diff=5, BOP=0.333...
|
// Range=15, Diff=5, BOP=0.333...
|
||||||
Assert.Equal(1.0/3.0, bop, 6);
|
Assert.Equal(1.0 / 3.0, bop, 6);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ public class BopIndicator : Indicator, IWatchlistIndicator
|
|||||||
private Bop? _bop;
|
private Bop? _bop;
|
||||||
protected LineSeries? BopSeries;
|
protected LineSeries? BopSeries;
|
||||||
|
|
||||||
public int MinHistoryDepths => 0;
|
public static int MinHistoryDepths => 0;
|
||||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||||
|
|
||||||
public override string ShortName => "BOP";
|
public override string ShortName => "BOP";
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ public class BopTests
|
|||||||
bars.Add(new TBar(DateTime.UtcNow, 10, 20, 5, 15, 100));
|
bars.Add(new TBar(DateTime.UtcNow, 10, 20, 5, 15, 100));
|
||||||
bars.Add(new TBar(DateTime.UtcNow.AddMinutes(1), 15, 25, 10, 20, 100));
|
bars.Add(new TBar(DateTime.UtcNow.AddMinutes(1), 15, 25, 10, 20, 100));
|
||||||
|
|
||||||
var batchResult = bop.Update(bars);
|
var batchResult = Bop.Update(bars);
|
||||||
|
|
||||||
bop.Reset();
|
bop.Reset();
|
||||||
var streamResult1 = bop.Update(bars[0]);
|
var streamResult1 = bop.Update(bars[0]);
|
||||||
|
|||||||
+26
-20
@@ -30,7 +30,7 @@ public sealed class Bop : ITValuePublisher
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Display name for the indicator.
|
/// Display name for the indicator.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Name => "Bop";
|
public static string Name => "Bop";
|
||||||
|
|
||||||
public event Action<TValue>? Pub;
|
public event Action<TValue>? Pub;
|
||||||
|
|
||||||
@@ -42,12 +42,12 @@ public sealed class Bop : ITValuePublisher
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// True if the indicator has a valid value (always true for BOP as it has no warmup).
|
/// True if the indicator has a valid value (always true for BOP as it has no warmup).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsHot => true;
|
public static bool IsHot => true;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The number of bars required for the indicator to warm up.
|
/// The number of bars required for the indicator to warm up.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int WarmupPeriod => 0;
|
public static int WarmupPeriod => 0;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resets the indicator state.
|
/// Resets the indicator state.
|
||||||
@@ -99,7 +99,7 @@ public sealed class Bop : ITValuePublisher
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Updates the indicator with a series of bars.
|
/// Updates the indicator with a series of bars.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public TSeries Update(TBarSeries source)
|
public static TSeries Update(TBarSeries source)
|
||||||
{
|
{
|
||||||
return Batch(source);
|
return Batch(source);
|
||||||
}
|
}
|
||||||
@@ -118,13 +118,18 @@ public sealed class Bop : ITValuePublisher
|
|||||||
if (Vector.IsHardwareAccelerated && len >= Vector<double>.Count)
|
if (Vector.IsHardwareAccelerated && len >= Vector<double>.Count)
|
||||||
{
|
{
|
||||||
var epsilon = new Vector<double>(double.Epsilon);
|
var epsilon = new Vector<double>(double.Epsilon);
|
||||||
var vectors = len / Vector<double>.Count;
|
ref var oRef = ref MemoryMarshal.GetReference(open);
|
||||||
for (int j = 0; j < vectors; j++)
|
ref var hRef = ref MemoryMarshal.GetReference(high);
|
||||||
|
ref var lRef = ref MemoryMarshal.GetReference(low);
|
||||||
|
ref var cRef = ref MemoryMarshal.GetReference(close);
|
||||||
|
ref var dRef = ref MemoryMarshal.GetReference(destination);
|
||||||
|
|
||||||
|
while (i <= len - Vector<double>.Count)
|
||||||
{
|
{
|
||||||
var o = new Vector<double>(open.Slice(i, Vector<double>.Count));
|
var o = Vector.LoadUnsafe(ref oRef, (nuint)i);
|
||||||
var h = new Vector<double>(high.Slice(i, Vector<double>.Count));
|
var h = Vector.LoadUnsafe(ref hRef, (nuint)i);
|
||||||
var l = new Vector<double>(low.Slice(i, Vector<double>.Count));
|
var l = Vector.LoadUnsafe(ref lRef, (nuint)i);
|
||||||
var c = new Vector<double>(close.Slice(i, Vector<double>.Count));
|
var c = Vector.LoadUnsafe(ref cRef, (nuint)i);
|
||||||
|
|
||||||
var range = h - l;
|
var range = h - l;
|
||||||
var body = c - o;
|
var body = c - o;
|
||||||
@@ -138,7 +143,7 @@ public sealed class Bop : ITValuePublisher
|
|||||||
// Select div where mask is true, otherwise 0
|
// Select div where mask is true, otherwise 0
|
||||||
var result = Vector.ConditionalSelect(mask, div, Vector<double>.Zero);
|
var result = Vector.ConditionalSelect(mask, div, Vector<double>.Zero);
|
||||||
|
|
||||||
result.CopyTo(destination.Slice(i, Vector<double>.Count));
|
result.StoreUnsafe(ref dRef, (nuint)i);
|
||||||
|
|
||||||
i += Vector<double>.Count;
|
i += Vector<double>.Count;
|
||||||
}
|
}
|
||||||
@@ -160,17 +165,18 @@ public sealed class Bop : ITValuePublisher
|
|||||||
if (source.Count == 0) return new TSeries([], []);
|
if (source.Count == 0) return new TSeries([], []);
|
||||||
|
|
||||||
var len = source.Count;
|
var len = source.Count;
|
||||||
var v = new double[len];
|
|
||||||
|
var t = new List<long>(len);
|
||||||
|
var v = new List<double>(len);
|
||||||
|
CollectionsMarshal.SetCount(t, len);
|
||||||
|
CollectionsMarshal.SetCount(v, len);
|
||||||
|
|
||||||
Calculate(source.Open.Values, source.High.Values, source.Low.Values, source.Close.Values, v);
|
var tSpan = CollectionsMarshal.AsSpan(t);
|
||||||
|
var vSpan = CollectionsMarshal.AsSpan(v);
|
||||||
|
|
||||||
var tList = new List<long>(len);
|
source.Open.Times.CopyTo(tSpan);
|
||||||
var times = source.Open.Times;
|
Calculate(source.Open.Values, source.High.Values, source.Low.Values, source.Close.Values, vSpan);
|
||||||
for (int i = 0; i < len; i++)
|
|
||||||
{
|
|
||||||
tList.Add(times[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new TSeries(tList, new List<double>(v));
|
return new TSeries(t, v);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user