// Vo: Mathematical property validation tests
// Volume Oscillator compares short and long SMAs of volume.
// No standard external library equivalents with matching implementation.
// Validation uses mathematical property testing.
using Tulip;
namespace QuanTAlib.Tests;
using Xunit;
public class VoValidationTests
{
private const int DefaultShortPeriod = 5;
private const int DefaultLongPeriod = 10;
private const int DefaultSignalPeriod = 10;
private const int TestDataLength = 500;
[Fact]
public void Vo_Output_IsFiniteForGbmData()
{
var bars = new GBM(sigma: 0.5, seed: 123).Fetch(TestDataLength, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var vo = new Vo(DefaultShortPeriod, DefaultLongPeriod, DefaultSignalPeriod);
for (int i = 0; i < bars.Count; i++)
{
var result = vo.Update(bars[i], isNew: true);
Assert.True(double.IsFinite(result.Value),
$"Vo output must be finite at bar {i}, got {result.Value}");
}
}
[Fact]
public void Vo_ConstantVolume_ZeroOscillator()
{
var vo = new Vo(DefaultShortPeriod, DefaultLongPeriod, DefaultSignalPeriod);
// Feed bars with identical volume
for (int i = 0; i < 50; i++)
{
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i),
100, 101, 99, 100, 1000); // constant volume
vo.Update(bar, isNew: true);
}
// When volume is constant, short MA == long MA, VO = 0
Assert.Equal(0.0, vo.Last.Value, precision: 8);
}
[Fact]
public void Vo_IncreasingVolume_PositiveOscillator()
{
var vo = new Vo(DefaultShortPeriod, DefaultLongPeriod, DefaultSignalPeriod);
// Feed bars with steadily increasing volume
for (int i = 0; i < 50; i++)
{
double volume = 1000 + i * 100; // increasing
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i),
100, 101, 99, 100, volume);
vo.Update(bar, isNew: true);
}
// Short MA should be higher than long MA when volume is increasing
Assert.True(vo.Last.Value > 0,
$"VO should be positive with increasing volume, got {vo.Last.Value}");
}
[Fact]
public void Vo_DecreasingVolume_NegativeOscillator()
{
var vo = new Vo(DefaultShortPeriod, DefaultLongPeriod, DefaultSignalPeriod);
// Feed bars with steadily decreasing volume
for (int i = 0; i < 50; i++)
{
double volume = 10000 - i * 100; // decreasing
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i),
100, 101, 99, 100, volume);
vo.Update(bar, isNew: true);
}
// Short MA should be lower than long MA when volume is decreasing
Assert.True(vo.Last.Value < 0,
$"VO should be negative with decreasing volume, got {vo.Last.Value}");
}
[Fact]
public void Vo_Signal_IsFinite()
{
var bars = new GBM(sigma: 0.5, seed: 123).Fetch(TestDataLength, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var vo = new Vo(DefaultShortPeriod, DefaultLongPeriod, DefaultSignalPeriod);
for (int i = 0; i < bars.Count; i++)
{
vo.Update(bars[i], isNew: true);
Assert.True(double.IsFinite(vo.Signal),
$"Signal must be finite at bar {i}, got {vo.Signal}");
}
}
[Fact]
public void Vo_ConstantVolume_SignalAlsoZero()
{
var vo = new Vo(DefaultShortPeriod, DefaultLongPeriod, DefaultSignalPeriod);
for (int i = 0; i < 50; i++)
{
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i),
100, 101, 99, 100, 1000);
vo.Update(bar, isNew: true);
}
// Signal is SMA of VO values, all of which are zero
Assert.Equal(0.0, vo.Signal, precision: 8);
}
[Fact]
public void Vo_BatchAndStreaming_ProduceSameResults()
{
var bars = new GBM(sigma: 0.5, seed: 123).Fetch(TestDataLength, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
// Batch
var batchResults = Vo.Batch(bars, DefaultShortPeriod, DefaultLongPeriod, DefaultSignalPeriod);
// Streaming
var streamVo = new Vo(DefaultShortPeriod, DefaultLongPeriod, DefaultSignalPeriod);
var streamResults = new double[bars.Count];
for (int i = 0; i < bars.Count; i++)
{
var result = streamVo.Update(bars[i], isNew: true);
streamResults[i] = result.Value;
}
Assert.Equal(batchResults.Count, bars.Count);
for (int i = 0; i < bars.Count; i++)
{
Assert.Equal(batchResults.Values[i], streamResults[i], precision: 8);
}
}
[Fact]
public void Vo_DifferentPeriods_ProduceDifferentResults()
{
var bars = new GBM(sigma: 0.5, seed: 123).Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var vo1 = new Vo(3, 7, 5);
var vo2 = new Vo(10, 30, 15);
for (int i = 0; i < bars.Count; i++)
{
vo1.Update(bars[i], isNew: true);
vo2.Update(bars[i], isNew: true);
}
Assert.NotEqual(vo1.Last.Value, vo2.Last.Value);
}
[Fact]
public void Vo_BarCorrection_IsNewFalse_RestoresState()
{
var bars = new GBM(sigma: 0.5, seed: 123).Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var vo = new Vo(DefaultShortPeriod, DefaultLongPeriod, DefaultSignalPeriod);
for (int i = 0; i < 30; i++)
{
vo.Update(bars[i], isNew: true);
}
vo.Update(bars[30], isNew: true);
double afterNew = vo.Last.Value;
vo.Update(bars[30], isNew: false);
double afterCorrection = vo.Last.Value;
Assert.Equal(afterNew, afterCorrection, precision: 10);
}
[Fact]
public void Vo_IsHot_AfterLongPeriod()
{
var vo = new Vo(DefaultShortPeriod, DefaultLongPeriod, DefaultSignalPeriod);
for (int i = 0; i < DefaultLongPeriod - 1; i++)
{
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 100, 101, 99, 100, 1000);
vo.Update(bar, isNew: true);
Assert.False(vo.IsHot, $"Should not be hot at bar {i}");
}
// Bar at index longPeriod-1 should make it hot (Index becomes longPeriod)
var finalBar = new TBar(DateTime.UtcNow.AddMinutes(DefaultLongPeriod), 100, 101, 99, 100, 1000);
vo.Update(finalBar, isNew: true);
Assert.True(vo.IsHot, "Should be hot after longPeriod bars");
}
// === Tulip Cross-Validation ===
///
/// Structural validation against Tulip vosc (volume oscillator).
/// Algorithm variant: Tulip vosc takes one input (volume only) with two options
/// (short_period, long_period) and computes (sma_short - sma_long) / sma_long × 100.
/// QuanTAlib Vo also adds an optional signal EMA. With signalPeriod=1 the signal
/// equals Vo itself, so raw Vo output is directly comparable to Tulip vosc.
///
[Fact]
public void Vo_Matches_Tulip_Vosc_Batch()
{
const int shortPeriod = 5;
const int longPeriod = 10;
var bars = new GBM(sigma: 0.3, seed: 42).Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
double[] volumeData = new double[bars.Count];
for (int i = 0; i < bars.Count; i++) { volumeData[i] = bars[i].Volume; }
// QuanTAlib Vo batch
var qResult = Vo.Batch(bars, shortPeriod, longPeriod, signalPeriod: 1);
// Tulip vosc — volume only, no signal period
var tulipIndicator = Tulip.Indicators.vosc;
double[][] inputs = { volumeData };
double[] options = { shortPeriod, longPeriod };
int lookback = tulipIndicator.Start(options);
double[][] outputs = { new double[volumeData.Length - lookback] };
tulipIndicator.Run(inputs, options, outputs);
double[] tResult = outputs[0];
ValidationHelper.VerifyData(qResult, tResult, lookback);
}
[Fact]
public void Vo_Matches_Tulip_Vosc_Streaming()
{
const int shortPeriod = 5;
const int longPeriod = 10;
var bars = new GBM(sigma: 0.3, seed: 42).Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
double[] volumeData = new double[bars.Count];
for (int i = 0; i < bars.Count; i++) { volumeData[i] = bars[i].Volume; }
// QuanTAlib Vo streaming (signalPeriod=1 → signal equals Vo)
var vo = new Vo(shortPeriod, longPeriod, signalPeriod: 1);
var qResults = new List();
foreach (var bar in bars) { qResults.Add(vo.Update(bar).Value); }
// Tulip vosc
var tulipIndicator = Tulip.Indicators.vosc;
double[][] inputs = { volumeData };
double[] options = { shortPeriod, longPeriod };
int lookback = tulipIndicator.Start(options);
double[][] outputs = { new double[volumeData.Length - lookback] };
tulipIndicator.Run(inputs, options, outputs);
double[] tResult = outputs[0];
ValidationHelper.VerifyData(qResults, tResult, lookback);
}
}