volume indicators

This commit is contained in:
Miha Kralj
2026-01-30 12:47:25 -08:00
parent 76d2b50cbb
commit 7b3a6520d2
99 changed files with 9539 additions and 283 deletions
+313
View File
@@ -0,0 +1,313 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class VoIndicatorTests
{
[Fact]
public void VoIndicator_Constructor_SetsDefaults()
{
var indicator = new VoIndicator();
Assert.Equal("VO - Volume Oscillator", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
Assert.Equal(5, indicator.ShortPeriod);
Assert.Equal(10, indicator.LongPeriod);
Assert.Equal(10, indicator.SignalPeriod);
Assert.Equal(10, indicator.MinHistoryDepths);
}
[Fact]
public void VoIndicator_ShortName_ReflectsPeriods()
{
var indicator = new VoIndicator { ShortPeriod = 3, LongPeriod = 7, SignalPeriod = 5 };
Assert.Equal("VO(3,7,5)", indicator.ShortName);
}
[Fact]
public void VoIndicator_MinHistoryDepths_EqualsLongPeriod()
{
var indicator = new VoIndicator { LongPeriod = 20 };
Assert.Equal(20, indicator.MinHistoryDepths);
Assert.Equal(20, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void VoIndicator_Periods_CanBeSet()
{
var indicator = new VoIndicator
{
ShortPeriod = 12,
LongPeriod = 26,
SignalPeriod = 9
};
Assert.Equal(12, indicator.ShortPeriod);
Assert.Equal(26, indicator.LongPeriod);
Assert.Equal(9, indicator.SignalPeriod);
}
[Fact]
public void VoIndicator_Initialize_CreatesInternalVo()
{
var indicator = new VoIndicator();
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist (VO + Signal)
Assert.Equal(2, indicator.LinesSeries.Count);
}
[Fact]
public void VoIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new VoIndicator { ShortPeriod = 5, LongPeriod = 10, SignalPeriod = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
double volume = 100000 + i * 1000;
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 110, 90, 105, volume);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
double voVal = indicator.LinesSeries[0].GetValue(0);
double signalVal = indicator.LinesSeries[1].GetValue(0);
Assert.True(double.IsFinite(voVal));
Assert.True(double.IsFinite(signalVal));
}
[Fact]
public void VoIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new VoIndicator { ShortPeriod = 5, LongPeriod = 10, SignalPeriod = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 110, 90, 105, 100000);
}
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// Add new bar
indicator.HistoricalData.AddBar(now.AddMinutes(30), 105, 115, 100, 112, 80000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(2, indicator.LinesSeries[0].Count);
Assert.Equal(2, indicator.LinesSeries[1].Count);
}
[Fact]
public void VoIndicator_ConstantVolume_ZeroOscillator()
{
var indicator = new VoIndicator { ShortPeriod = 3, LongPeriod = 6, SignalPeriod = 3 };
indicator.Initialize();
var now = DateTime.UtcNow;
// All bars with same volume
for (int i = 0; i < 20; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 105, 95, 100, 50000);
var args = i == 0
? new UpdateArgs(UpdateReason.HistoricalBar)
: new UpdateArgs(UpdateReason.NewBar);
indicator.ProcessUpdate(args);
}
double voVal = indicator.LinesSeries[0].GetValue(0);
Assert.Equal(0, voVal, 1);
}
[Fact]
public void VoIndicator_IncreasingVolume_PositiveOscillator()
{
var indicator = new VoIndicator { ShortPeriod = 3, LongPeriod = 6, SignalPeriod = 3 };
indicator.Initialize();
var now = DateTime.UtcNow;
// Volume increases over time - short MA will exceed long MA
for (int i = 0; i < 20; i++)
{
double volume = 10000 + i * 5000; // Increasing volume
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 105, 95, 100, volume);
var args = i == 0
? new UpdateArgs(UpdateReason.HistoricalBar)
: new UpdateArgs(UpdateReason.NewBar);
indicator.ProcessUpdate(args);
}
double voVal = indicator.LinesSeries[0].GetValue(0);
Assert.True(voVal > 0, $"VO should be positive when volume increasing: {voVal}");
}
[Fact]
public void VoIndicator_DecreasingVolume_NegativeOscillator()
{
var indicator = new VoIndicator { ShortPeriod = 3, LongPeriod = 6, SignalPeriod = 3 };
indicator.Initialize();
var now = DateTime.UtcNow;
// Volume decreases over time - short MA will be below long MA
for (int i = 0; i < 20; i++)
{
double volume = 100000 - i * 4000; // Decreasing volume
volume = Math.Max(volume, 1000); // Keep positive
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 105, 95, 100, volume);
var args = i == 0
? new UpdateArgs(UpdateReason.HistoricalBar)
: new UpdateArgs(UpdateReason.NewBar);
indicator.ProcessUpdate(args);
}
double voVal = indicator.LinesSeries[0].GetValue(0);
Assert.True(voVal < 0, $"VO should be negative when volume decreasing: {voVal}");
}
[Fact]
public void VoIndicator_SignalLine_SmoothsVo()
{
var indicator = new VoIndicator { ShortPeriod = 3, LongPeriod = 6, SignalPeriod = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
var voValues = new List<double>();
var signalValues = new List<double>();
// Add oscillating volume
for (int i = 0; i < 30; i++)
{
double volume = 50000 + (i % 2 == 0 ? 20000 : -10000);
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 105, 95, 100, volume);
var args = i == 0
? new UpdateArgs(UpdateReason.HistoricalBar)
: new UpdateArgs(UpdateReason.NewBar);
indicator.ProcessUpdate(args);
if (i >= 10) // After warmup
{
voValues.Add(indicator.LinesSeries[0].GetValue(0));
signalValues.Add(indicator.LinesSeries[1].GetValue(0));
}
}
// Signal line should be smoother (smaller range)
double voRange = voValues.Max() - voValues.Min();
double signalRange = signalValues.Max() - signalValues.Min();
Assert.True(signalRange <= voRange, $"Signal should be smoother: VO range={voRange}, Signal range={signalRange}");
}
[Fact]
public void VoIndicator_DifferentPeriods_DifferentResults()
{
var shortPeriods = new VoIndicator { ShortPeriod = 3, LongPeriod = 6, SignalPeriod = 3 };
shortPeriods.Initialize();
var longPeriods = new VoIndicator { ShortPeriod = 10, LongPeriod = 20, SignalPeriod = 10 };
longPeriods.Initialize();
var now = DateTime.UtcNow;
// Add same data to both
for (int i = 0; i < 50; i++)
{
double volume = 50000 + Math.Sin(i * 0.3) * 20000;
shortPeriods.HistoricalData.AddBar(now.AddMinutes(i), 100, 105, 95, 100, volume);
longPeriods.HistoricalData.AddBar(now.AddMinutes(i), 100, 105, 95, 100, volume);
var args = i == 0
? new UpdateArgs(UpdateReason.HistoricalBar)
: new UpdateArgs(UpdateReason.NewBar);
shortPeriods.ProcessUpdate(args);
longPeriods.ProcessUpdate(args);
}
double shortVal = shortPeriods.LinesSeries[0].GetValue(0);
double longVal = longPeriods.LinesSeries[0].GetValue(0);
// Different periods should produce different results
Assert.NotEqual(shortVal, longVal, 3);
}
[Fact]
public void VoIndicator_ReturnsPercentage()
{
var indicator = new VoIndicator { ShortPeriod = 2, LongPeriod = 4, SignalPeriod = 2 };
indicator.Initialize();
var now = DateTime.UtcNow;
// Start with baseline volume
for (int i = 0; i < 5; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 105, 95, 100, 10000);
var args = i == 0
? new UpdateArgs(UpdateReason.HistoricalBar)
: new UpdateArgs(UpdateReason.NewBar);
indicator.ProcessUpdate(args);
}
// Add bar with significantly higher volume
indicator.HistoricalData.AddBar(now.AddMinutes(5), 100, 105, 95, 100, 20000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
double voVal = indicator.LinesSeries[0].GetValue(0);
// VO should be positive percentage (short MA > long MA)
Assert.True(voVal > 0, $"VO should be positive: {voVal}");
Assert.True(voVal <= 200, $"VO should be reasonable percentage: {voVal}"); // Not too extreme
}
[Fact]
public void VoIndicator_OscillatesAroundZero()
{
var indicator = new VoIndicator { ShortPeriod = 5, LongPeriod = 10, SignalPeriod = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
bool hasPositive = false;
bool hasNegative = false;
// Oscillating volume pattern
for (int i = 0; i < 50; i++)
{
double volume = 50000 + Math.Sin(i * 0.5) * 30000;
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 105, 95, 100, volume);
var args = i == 0
? new UpdateArgs(UpdateReason.HistoricalBar)
: new UpdateArgs(UpdateReason.NewBar);
indicator.ProcessUpdate(args);
if (i > 15) // After warmup
{
double val = indicator.LinesSeries[0].GetValue(0);
if (val > 0.5)
{
hasPositive = true;
}
if (val < -0.5)
{
hasNegative = true;
}
}
}
Assert.True(hasPositive && hasNegative, "VO should oscillate around zero");
}
}
+63
View File
@@ -0,0 +1,63 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class VoIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Short Period", sortIndex: 10, minimum: 1, maximum: 500, increment: 1)]
public int ShortPeriod { get; set; } = 5;
[InputParameter("Long Period", sortIndex: 11, minimum: 2, maximum: 1000, increment: 1)]
public int LongPeriod { get; set; } = 10;
[InputParameter("Signal Period", sortIndex: 12, minimum: 1, maximum: 500, increment: 1)]
public int SignalPeriod { get; set; } = 10;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Vo _vo = null!;
private readonly LineSeries _voSeries;
private readonly LineSeries _signalSeries;
#pragma warning disable S2325 // Instance property required by Quantower indicator interface
public int MinHistoryDepths => LongPeriod;
#pragma warning restore S2325
int IWatchlistIndicator.MinHistoryDepths => LongPeriod;
public override string ShortName => $"VO({ShortPeriod},{LongPeriod},{SignalPeriod})";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/volume/vo/Vo.Quantower.cs";
public VoIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "VO - Volume Oscillator";
Description = "Measures the difference between two volume moving averages as a percentage.";
_voSeries = new LineSeries(name: "VO", color: Color.Yellow, width: 2, style: LineStyle.Solid);
_signalSeries = new LineSeries(name: "Signal", color: Color.Blue, width: 2, style: LineStyle.Solid);
AddLineSeries(_voSeries);
AddLineSeries(_signalSeries);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_vo = new Vo(ShortPeriod, LongPeriod, SignalPeriod);
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
TBar bar = this.GetInputBar(args);
TValue result = _vo.Update(bar, args.IsNewBar());
_voSeries.SetValue(result.Value, _vo.IsHot, ShowColdValues);
_signalSeries.SetValue(_vo.Signal, _vo.IsHot, ShowColdValues);
}
}
+602
View File
@@ -0,0 +1,602 @@
using Xunit;
namespace QuanTAlib.Tests;
public class VoTests
{
private const double Tolerance = 1e-10;
private readonly GBM _gbm;
private readonly TBarSeries _bars;
public VoTests()
{
_gbm = new GBM(seed: 42);
_bars = _gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
}
#region Constructor Tests
[Fact]
public void Constructor_DefaultPeriods_SetsExpectedValues()
{
var vo = new Vo();
Assert.Equal("Vo(5,10,10)", vo.Name);
Assert.Equal(10, vo.WarmupPeriod);
}
[Fact]
public void Constructor_CustomPeriods_SetsExpectedValues()
{
var vo = new Vo(shortPeriod: 3, longPeriod: 7, signalPeriod: 5);
Assert.Equal("Vo(3,7,5)", vo.Name);
Assert.Equal(7, vo.WarmupPeriod);
}
[Fact]
public void Constructor_ShortPeriodLessThan1_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Vo(shortPeriod: 0));
Assert.Equal("shortPeriod", ex.ParamName);
}
[Fact]
public void Constructor_LongPeriodLessThan1_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Vo(shortPeriod: 2, longPeriod: 0));
Assert.Equal("longPeriod", ex.ParamName);
}
[Fact]
public void Constructor_ShortPeriodGreaterOrEqualLongPeriod_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Vo(shortPeriod: 10, longPeriod: 10));
Assert.Equal("shortPeriod", ex.ParamName);
ex = Assert.Throws<ArgumentException>(() => new Vo(shortPeriod: 15, longPeriod: 10));
Assert.Equal("shortPeriod", ex.ParamName);
}
[Fact]
public void Constructor_SignalPeriodLessThan1_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Vo(shortPeriod: 5, longPeriod: 10, signalPeriod: 0));
Assert.Equal("signalPeriod", ex.ParamName);
}
#endregion
#region Basic Calculation Tests
[Fact]
public void Update_ReturnsTValue()
{
var vo = new Vo();
var result = vo.Update(_bars[0]);
Assert.IsType<TValue>(result);
}
[Fact]
public void Update_AccessesLastAndSignal()
{
var vo = new Vo();
vo.Update(_bars[0]);
Assert.Equal(vo.Last.Value, vo.Update(_bars[0], isNew: false).Value);
_ = vo.Signal; // Access signal property
}
[Fact]
public void Update_SameVolumes_ReturnsZero()
{
var vo = new Vo(shortPeriod: 2, longPeriod: 4, signalPeriod: 2);
var now = DateTime.UtcNow;
// All same volumes should result in VO = 0
for (int i = 0; i < 10; i++)
{
var bar = new TBar(now.AddMinutes(i), 100, 100, 100, 100, 1000);
vo.Update(bar, isNew: true);
}
Assert.Equal(0.0, vo.Last.Value, Tolerance);
}
[Fact]
public void Update_IncreasingVolumes_ReturnsPositive()
{
var vo = new Vo(shortPeriod: 2, longPeriod: 4, signalPeriod: 2);
var now = DateTime.UtcNow;
// Create a pattern where short MA > long MA at the end
// Volumes: 100, 100, 100, 100, 500, 1000
// At bar 5 (index 5): short SMA (2) = (500+1000)/2 = 750
// long SMA (4) = (100+100+500+1000)/4 = 425
// VO = ((750 - 425) / 425) * 100 = 76.47% (positive)
double[] volumes = [100, 100, 100, 100, 500, 1000];
for (int i = 0; i < volumes.Length; i++)
{
var bar = new TBar(now.AddMinutes(i), 100, 100, 100, 100, volumes[i]);
vo.Update(bar, isNew: true);
}
Assert.True(vo.Last.Value > 0, $"Expected positive VO but got {vo.Last.Value}");
}
[Fact]
public void Update_DecreasingVolumes_ReturnsNegative()
{
var vo = new Vo(shortPeriod: 2, longPeriod: 4, signalPeriod: 2);
var now = DateTime.UtcNow;
// Create a pattern where short MA < long MA at the end
// Volumes: 1000, 1000, 1000, 1000, 500, 100
// At bar 5 (index 5): short SMA (2) = (500+100)/2 = 300
// long SMA (4) = (1000+1000+500+100)/4 = 650
// VO = ((300 - 650) / 650) * 100 = -53.85% (negative)
double[] volumes = [1000, 1000, 1000, 1000, 500, 100];
for (int i = 0; i < volumes.Length; i++)
{
var bar = new TBar(now.AddMinutes(i), 100, 100, 100, 100, volumes[i]);
vo.Update(bar, isNew: true);
}
Assert.True(vo.Last.Value < 0, $"Expected negative VO but got {vo.Last.Value}");
}
#endregion
#region State Management Tests
[Fact]
public void IsNew_True_AdvancesState()
{
var vo = new Vo(shortPeriod: 2, longPeriod: 4, signalPeriod: 2);
var now = DateTime.UtcNow;
// Feed enough bars to get past warmup with varying volumes
// to ensure state advances (index changes)
double[] volumes = [100, 200, 300, 400, 500];
for (int i = 0; i < volumes.Length; i++)
{
var bar = new TBar(now.AddMinutes(i), 100, 100, 100, 100, volumes[i]);
vo.Update(bar, isNew: true);
}
var stateBeforeNewBar = vo.Last.Value;
// Add another bar with different volume
var newBar = new TBar(now.AddMinutes(5), 100, 100, 100, 100, 1000);
vo.Update(newBar, isNew: true);
// State should have advanced (different value due to new volume in moving averages)
Assert.NotEqual(stateBeforeNewBar, vo.Last.Value);
}
[Fact]
public void IsNew_False_UpdatesCurrentBar()
{
var vo = new Vo(shortPeriod: 2, longPeriod: 4, signalPeriod: 2);
var now = DateTime.UtcNow;
var bar1 = new TBar(now, 100, 100, 100, 100, 500);
vo.Update(bar1, isNew: true);
var bar2 = new TBar(now, 100, 100, 100, 100, 600);
vo.Update(bar2, isNew: false);
var bar3 = new TBar(now, 100, 100, 100, 100, 500);
var result = vo.Update(bar3, isNew: false);
Assert.Equal(vo.Update(bar1, isNew: false).Value, result.Value, Tolerance);
}
[Fact]
public void IterativeCorrections_RestoreState()
{
var vo = new Vo(shortPeriod: 3, longPeriod: 6, signalPeriod: 3);
var now = DateTime.UtcNow;
// Add several bars
for (int i = 0; i < 10; i++)
{
var bar = new TBar(now.AddMinutes(i), 100, 100, 100, 100, 500 + i * 10);
vo.Update(bar, isNew: true);
}
var stateBeforeCorrections = vo.Last.Value;
// Apply multiple corrections
for (int j = 0; j < 5; j++)
{
var correctionBar = new TBar(now.AddMinutes(9), 100, 100, 100, 100, 700 + j * 10);
vo.Update(correctionBar, isNew: false);
}
// Restore original bar
var originalBar = new TBar(now.AddMinutes(9), 100, 100, 100, 100, 590);
var restored = vo.Update(originalBar, isNew: false);
Assert.Equal(stateBeforeCorrections, restored.Value, Tolerance);
}
[Fact]
public void Reset_ClearsState()
{
var vo = new Vo();
// Process some bars
for (int i = 0; i < 20; i++)
{
vo.Update(_bars[i], isNew: true);
}
Assert.True(vo.IsHot);
vo.Reset();
Assert.False(vo.IsHot);
Assert.Equal(default, vo.Last);
}
#endregion
#region Warmup Tests
[Fact]
public void IsHot_BeforeWarmup_ReturnsFalse()
{
var vo = new Vo(shortPeriod: 3, longPeriod: 10, signalPeriod: 5);
var now = DateTime.UtcNow;
for (int i = 0; i < 9; i++)
{
var bar = new TBar(now.AddMinutes(i), 100, 100, 100, 100, 500);
vo.Update(bar, isNew: true);
Assert.False(vo.IsHot, $"Should not be hot at index {i}");
}
}
[Fact]
public void IsHot_AfterWarmup_ReturnsTrue()
{
var vo = new Vo(shortPeriod: 3, longPeriod: 10, signalPeriod: 5);
var now = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
var bar = new TBar(now.AddMinutes(i), 100, 100, 100, 100, 500);
vo.Update(bar, isNew: true);
}
Assert.True(vo.IsHot);
}
[Fact]
public void WarmupPeriod_EqualsLongPeriod()
{
var vo = new Vo(shortPeriod: 5, longPeriod: 15, signalPeriod: 10);
Assert.Equal(15, vo.WarmupPeriod);
}
#endregion
#region Robustness Tests
[Fact]
public void Update_NaN_UsesLastValidValue()
{
var vo = new Vo(shortPeriod: 2, longPeriod: 4, signalPeriod: 2);
var now = DateTime.UtcNow;
// Add valid bars
for (int i = 0; i < 5; i++)
{
var bar = new TBar(now.AddMinutes(i), 100, 100, 100, 100, 500);
vo.Update(bar, isNew: true);
}
// Add bar with NaN volume
var nanBar = new TBar(now.AddMinutes(5), 100, 100, 100, 100, double.NaN);
var result = vo.Update(nanBar, isNew: true);
Assert.True(double.IsFinite(result.Value), "Result should be finite after NaN input");
}
[Fact]
public void Update_Infinity_UsesLastValidValue()
{
var vo = new Vo(shortPeriod: 2, longPeriod: 4, signalPeriod: 2);
var now = DateTime.UtcNow;
// Add valid bars
for (int i = 0; i < 5; i++)
{
var bar = new TBar(now.AddMinutes(i), 100, 100, 100, 100, 500);
vo.Update(bar, isNew: true);
}
// Add bar with Infinity volume
var infBar = new TBar(now.AddMinutes(5), 100, 100, 100, 100, double.PositiveInfinity);
var result = vo.Update(infBar, isNew: true);
Assert.True(double.IsFinite(result.Value), "Result should be finite after Infinity input");
}
[Fact]
public void BatchUpdate_WithNaN_Safe()
{
var vo = new Vo();
var bars = new TBarSeries();
var now = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
double volume = i == 10 ? double.NaN : 500 + i;
bars.Add(new TBar(now.AddMinutes(i), 100, 100, 100, 100, volume));
}
var result = vo.Update(bars);
Assert.Equal(20, result.Count);
foreach (var val in result.Values)
{
Assert.True(double.IsFinite(val), "All values should be finite");
}
}
#endregion
#region Consistency Tests
[Fact]
public void BatchCalc_EqualsStreaming()
{
var vo = new Vo(shortPeriod: 5, longPeriod: 10, signalPeriod: 10);
// Streaming
var streamingResults = new List<double>();
for (int i = 0; i < _bars.Count; i++)
{
var result = vo.Update(_bars[i], isNew: true);
streamingResults.Add(result.Value);
}
// Batch
var batchResult = Vo.Calculate(_bars, shortPeriod: 5, longPeriod: 10, signalPeriod: 10);
Assert.Equal(streamingResults.Count, batchResult.Count);
for (int i = 0; i < streamingResults.Count; i++)
{
Assert.Equal(streamingResults[i], batchResult.Values[i], Tolerance);
}
}
[Fact]
public void SpanCalc_EqualsStreaming()
{
var vo = new Vo(shortPeriod: 5, longPeriod: 10, signalPeriod: 10);
// Streaming
var streamingResults = new List<double>();
for (int i = 0; i < _bars.Count; i++)
{
var result = vo.Update(_bars[i], isNew: true);
streamingResults.Add(result.Value);
}
// Span - pass arrays directly (implicit span conversion)
var volume = _bars.Volume.Values.ToArray();
var output = new double[_bars.Count];
Vo.Calculate(volume, output, shortPeriod: 5, longPeriod: 10);
for (int i = 0; i < streamingResults.Count; i++)
{
Assert.Equal(streamingResults[i], output[i], Tolerance);
}
}
[Fact]
public void BatchUpdate_EqualsStreaming()
{
var voStream = new Vo(shortPeriod: 5, longPeriod: 10, signalPeriod: 10);
var voBatch = new Vo(shortPeriod: 5, longPeriod: 10, signalPeriod: 10);
// Streaming
for (int i = 0; i < _bars.Count; i++)
{
voStream.Update(_bars[i], isNew: true);
}
// Batch
var batchResult = voBatch.Update(_bars);
Assert.Equal(voStream.Last.Value, batchResult.Values[^1], Tolerance);
}
#endregion
#region Span API Tests
[Fact]
public void Calculate_Span_ValidatesLengths()
{
var volume = new double[100];
var output = new double[50]; // Wrong length
ArgumentException? caught = null;
try
{
Vo.Calculate(volume, output, shortPeriod: 5, longPeriod: 10);
}
catch (ArgumentException ex)
{
caught = ex;
}
Assert.NotNull(caught);
Assert.Equal("output", caught.ParamName);
}
[Fact]
public void Calculate_Span_ValidatesShortPeriod()
{
var volume = new double[100];
var output = new double[100];
ArgumentException? caught = null;
try
{
Vo.Calculate(volume, output, shortPeriod: 0, longPeriod: 10);
}
catch (ArgumentException ex)
{
caught = ex;
}
Assert.NotNull(caught);
Assert.Equal("shortPeriod", caught.ParamName);
}
[Fact]
public void Calculate_Span_ValidatesLongPeriod()
{
var volume = new double[100];
var output = new double[100];
ArgumentException? caught = null;
try
{
Vo.Calculate(volume, output, shortPeriod: 5, longPeriod: 0);
}
catch (ArgumentException ex)
{
caught = ex;
}
Assert.NotNull(caught);
Assert.Equal("longPeriod", caught.ParamName);
}
[Fact]
public void Calculate_Span_ValidatesShortLessThanLong()
{
var volume = new double[100];
var output = new double[100];
ArgumentException? caught = null;
try
{
Vo.Calculate(volume, output, shortPeriod: 10, longPeriod: 5);
}
catch (ArgumentException ex)
{
caught = ex;
}
Assert.NotNull(caught);
Assert.Equal("shortPeriod", caught.ParamName);
}
[Fact]
public void Calculate_Span_HandlesEmpty()
{
double[] volumeArr = [];
double[] outputArr = [];
// Should not throw
Vo.Calculate(volumeArr, outputArr, shortPeriod: 5, longPeriod: 10);
Assert.Empty(outputArr);
}
[Fact]
public void Calculate_Span_HandlesNaN()
{
var volume = new double[20];
var output = new double[20];
for (int i = 0; i < 20; i++)
{
volume[i] = i == 10 ? double.NaN : 500 + i;
}
Vo.Calculate(volume, output, shortPeriod: 5, longPeriod: 10);
foreach (var val in output)
{
Assert.True(double.IsFinite(val), "All values should be finite");
}
}
[Fact]
public void Calculate_Span_LargeData_NoStackOverflow()
{
var volume = new double[10000];
var output = new double[10000];
for (int i = 0; i < 10000; i++)
{
volume[i] = 500 + (i % 100);
}
// Should not throw stack overflow
Vo.Calculate(volume, output, shortPeriod: 50, longPeriod: 200);
Assert.True(double.IsFinite(output[^1]));
}
#endregion
#region Event Tests
[Fact]
public void Pub_FiresOnUpdate()
{
var vo = new Vo();
var eventFired = false;
vo.Pub += (object? sender, in TValueEventArgs args) => { eventFired = true; };
vo.Update(_bars[0]);
Assert.True(eventFired);
}
[Fact]
public void Pub_ChainingWorks()
{
var vo = new Vo();
var receivedValues = new List<double>();
vo.Pub += (object? sender, in TValueEventArgs args) => { receivedValues.Add(args.Value.Value); };
for (int i = 0; i < 20; i++)
{
vo.Update(_bars[i], isNew: true);
}
Assert.Equal(20, receivedValues.Count);
}
#endregion
#region TValue Input Tests
[Fact]
public void Update_TValue_PreservesLastValue()
{
var vo = new Vo();
var now = DateTime.UtcNow;
// First update with bar to set a value
var bar = new TBar(now, 100, 100, 100, 100, 500);
vo.Update(bar, isNew: true);
var lastValue = vo.Last.Value;
// TValue update should preserve last value (VO requires volume)
var tval = new TValue(now.AddMinutes(1), 200);
var result = vo.Update(tval, isNew: true);
Assert.Equal(lastValue, result.Value, Tolerance);
}
#endregion
}
+430
View File
@@ -0,0 +1,430 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// VO: Volume Oscillator
/// Measures the difference between two volume moving averages as a percentage,
/// with an optional signal line for trend confirmation.
/// </summary>
/// <remarks>
/// VO Formula:
/// short_ma = SMA(volume, short_period)
/// long_ma = SMA(volume, long_period)
/// VO = ((short_ma - long_ma) / long_ma) × 100
/// Signal = SMA(VO, signal_period)
///
/// Key characteristics:
/// - Positive when short-term volume exceeds long-term volume
/// - Negative when short-term volume is below long-term volume
/// - Signal line crossovers indicate momentum shifts
/// - Uses running sum for O(1) SMA updates
///
/// Sources:
/// PineScript reference: vo.pine
/// </remarks>
[SkipLocalsInit]
public sealed class Vo : ITValuePublisher
{
[StructLayout(LayoutKind.Auto)]
private record struct State(
double SumShort,
double SumLong,
double SumSignal,
int HeadShort,
int HeadLong,
int HeadSignal,
int CountShort,
int CountLong,
int CountSignal,
double LastValidVolume,
double SignalValue,
int Index);
private State _s;
private State _ps;
private readonly int _shortPeriod;
private readonly int _longPeriod;
private readonly int _signalPeriod;
private readonly double[] _bufferShort;
private readonly double[] _bufferLong;
private readonly double[] _bufferSignal;
private double[]? _pBufferShort;
private double[]? _pBufferLong;
private double[]? _pBufferSignal;
/// <inheritdoc/>
public TValue Last { get; private set; }
/// <summary>Gets the current signal line value.</summary>
public double Signal => _s.SignalValue;
/// <inheritdoc/>
public bool IsHot => _s.Index >= _longPeriod;
/// <inheritdoc/>
public int WarmupPeriod => _longPeriod;
/// <inheritdoc/>
public string Name { get; }
/// <inheritdoc/>
public event TValuePublishedHandler? Pub;
/// <summary>
/// Initializes a new instance of the VO indicator.
/// </summary>
/// <param name="shortPeriod">The short-term period (default: 5).</param>
/// <param name="longPeriod">The long-term period (default: 10).</param>
/// <param name="signalPeriod">The signal line period (default: 10).</param>
/// <exception cref="ArgumentException">Thrown when periods are invalid.</exception>
public Vo(int shortPeriod = 5, int longPeriod = 10, int signalPeriod = 10)
{
if (shortPeriod < 1)
{
throw new ArgumentException("Short period must be at least 1", nameof(shortPeriod));
}
if (longPeriod < 1)
{
throw new ArgumentException("Long period must be at least 1", nameof(longPeriod));
}
if (shortPeriod >= longPeriod)
{
throw new ArgumentException("Short period must be less than long period", nameof(shortPeriod));
}
if (signalPeriod < 1)
{
throw new ArgumentException("Signal period must be at least 1", nameof(signalPeriod));
}
_shortPeriod = shortPeriod;
_longPeriod = longPeriod;
_signalPeriod = signalPeriod;
_bufferShort = new double[shortPeriod];
_bufferLong = new double[longPeriod];
_bufferSignal = new double[signalPeriod];
Name = $"Vo({shortPeriod},{longPeriod},{signalPeriod})";
Reset();
}
/// <summary>
/// Resets the indicator to its initial state.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Reset()
{
_s = new State(
SumShort: 0, SumLong: 0, SumSignal: 0,
HeadShort: 0, HeadLong: 0, HeadSignal: 0,
CountShort: 0, CountLong: 0, CountSignal: 0,
LastValidVolume: 0, SignalValue: 0, Index: 0);
_ps = _s;
Array.Clear(_bufferShort);
Array.Clear(_bufferLong);
Array.Clear(_bufferSignal);
_pBufferShort = null;
_pBufferLong = null;
_pBufferSignal = null;
Last = default;
}
/// <summary>
/// Updates the VO with a new bar.
/// </summary>
/// <param name="input">The bar data.</param>
/// <param name="isNew">True if this is a new bar, false if updating current bar.</param>
/// <returns>The current VO value.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TBar input, bool isNew = true)
{
if (isNew)
{
_ps = _s;
_pBufferShort = (double[])_bufferShort.Clone();
_pBufferLong = (double[])_bufferLong.Clone();
_pBufferSignal = (double[])_bufferSignal.Clone();
}
else
{
_s = _ps;
if (_pBufferShort != null)
{
Array.Copy(_pBufferShort, _bufferShort, _shortPeriod);
}
if (_pBufferLong != null)
{
Array.Copy(_pBufferLong, _bufferLong, _longPeriod);
}
if (_pBufferSignal != null)
{
Array.Copy(_pBufferSignal, _bufferSignal, _signalPeriod);
}
}
var s = _s;
// Handle NaN/Infinity - substitute with last valid value
double volume = double.IsFinite(input.Volume) && input.Volume >= 0 ? input.Volume : s.LastValidVolume;
if (double.IsFinite(input.Volume) && input.Volume >= 0)
{
s.LastValidVolume = input.Volume;
}
// Ensure minimum volume of 1 to avoid division issues
volume = Math.Max(volume, 1.0);
// Update short SMA buffer
if (s.CountShort >= _shortPeriod)
{
s.SumShort -= _bufferShort[s.HeadShort];
}
else
{
s.CountShort++;
}
_bufferShort[s.HeadShort] = volume;
s.SumShort += volume;
s.HeadShort = (s.HeadShort + 1) % _shortPeriod;
// Update long SMA buffer
if (s.CountLong >= _longPeriod)
{
s.SumLong -= _bufferLong[s.HeadLong];
}
else
{
s.CountLong++;
}
_bufferLong[s.HeadLong] = volume;
s.SumLong += volume;
s.HeadLong = (s.HeadLong + 1) % _longPeriod;
// Calculate SMAs
double shortMa = s.CountShort > 0 ? s.SumShort / s.CountShort : volume;
double longMa = s.CountLong > 0 ? s.SumLong / s.CountLong : volume;
// Calculate VO
double voValue = longMa > 0 ? ((shortMa - longMa) / longMa) * 100.0 : 0.0;
// Update signal SMA buffer
if (s.CountSignal >= _signalPeriod)
{
s.SumSignal -= _bufferSignal[s.HeadSignal];
}
else
{
s.CountSignal++;
}
_bufferSignal[s.HeadSignal] = voValue;
s.SumSignal += voValue;
s.HeadSignal = (s.HeadSignal + 1) % _signalPeriod;
// Calculate signal line
s.SignalValue = s.CountSignal > 0 ? s.SumSignal / s.CountSignal : voValue;
if (isNew)
{
s.Index++;
}
_s = s;
Last = new TValue(input.Time, voValue);
Pub?.Invoke(this, new TValueEventArgs { Value = Last, IsNew = isNew });
return Last;
}
/// <summary>
/// Updates the VO with a TValue input.
/// </summary>
/// <remarks>
/// VO requires volume data for proper calculation. Using TValue without volume data
/// will keep VO unchanged.
/// </remarks>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TValue input, bool isNew = true)
{
// VO requires volume; without it, we can't compute
if (isNew)
{
_ps = _s;
}
else
{
_s = _ps;
}
Last = new TValue(input.Time, Last.Value);
Pub?.Invoke(this, new TValueEventArgs { Value = Last, IsNew = isNew });
return Last;
}
/// <summary>
/// Updates the VO with a series of bars (batch mode).
/// </summary>
/// <param name="source">The bar series.</param>
/// <returns>The result series.</returns>
public TSeries Update(TBarSeries source)
{
var t = new List<long>(source.Count);
var v = new List<double>(source.Count);
Reset();
for (int i = 0; i < source.Count; i++)
{
var val = Update(source[i], isNew: true);
t.Add(val.Time);
v.Add(val.Value);
}
return new TSeries(t, v);
}
/// <summary>
/// Calculates VO for a series of bars (static batch mode).
/// </summary>
/// <param name="source">The bar series.</param>
/// <param name="shortPeriod">The short-term period (default: 5).</param>
/// <param name="longPeriod">The long-term period (default: 10).</param>
/// <param name="signalPeriod">The signal line period (default: 10).</param>
/// <returns>The result series.</returns>
public static TSeries Calculate(TBarSeries source, int shortPeriod = 5, int longPeriod = 10, int signalPeriod = 10)
{
if (source.Count == 0)
{
return [];
}
var t = source.Open.Times.ToArray();
var v = new double[source.Count];
Calculate(source.Volume.Values, v, shortPeriod, longPeriod);
return new TSeries(t, v);
}
/// <summary>
/// Calculates VO for spans of volume data (high-performance span mode).
/// Note: This method computes only the VO values, not the signal line.
/// For signal line computation, use the instance Update methods.
/// </summary>
/// <param name="volume">The volume span.</param>
/// <param name="output">The output VO span.</param>
/// <param name="shortPeriod">The short-term period (default: 5).</param>
/// <param name="longPeriod">The long-term period (default: 10).</param>
/// <exception cref="ArgumentException">Thrown when parameters are invalid.</exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Calculate(ReadOnlySpan<double> volume, Span<double> output, int shortPeriod = 5, int longPeriod = 10)
{
if (shortPeriod < 1)
{
throw new ArgumentException("Short period must be at least 1", nameof(shortPeriod));
}
if (longPeriod < 1)
{
throw new ArgumentException("Long period must be at least 1", nameof(longPeriod));
}
if (shortPeriod >= longPeriod)
{
throw new ArgumentException("Short period must be less than long period", nameof(shortPeriod));
}
if (volume.Length != output.Length)
{
throw new ArgumentException("Output span must be of the same length as input", nameof(output));
}
int len = volume.Length;
if (len == 0)
{
return;
}
// Allocate buffers
const int StackallocThreshold = 256;
double[]? rentedShort = null;
double[]? rentedLong = null;
scoped Span<double> bufferShort;
scoped Span<double> bufferLong;
if (shortPeriod <= StackallocThreshold)
{
bufferShort = stackalloc double[shortPeriod];
}
else
{
rentedShort = System.Buffers.ArrayPool<double>.Shared.Rent(shortPeriod);
bufferShort = rentedShort.AsSpan(0, shortPeriod);
}
if (longPeriod <= StackallocThreshold)
{
bufferLong = stackalloc double[longPeriod];
}
else
{
rentedLong = System.Buffers.ArrayPool<double>.Shared.Rent(longPeriod);
bufferLong = rentedLong.AsSpan(0, longPeriod);
}
try
{
bufferShort.Clear();
bufferLong.Clear();
double sumShort = 0, sumLong = 0;
int headShort = 0, headLong = 0;
int countShort = 0, countLong = 0;
double lastValidVolume = 1.0;
for (int i = 0; i < len; i++)
{
// Get valid volume
double vol = double.IsFinite(volume[i]) && volume[i] >= 0 ? volume[i] : lastValidVolume;
if (double.IsFinite(volume[i]) && volume[i] >= 0)
{
lastValidVolume = volume[i];
}
vol = Math.Max(vol, 1.0);
// Update short SMA
if (countShort >= shortPeriod)
{
sumShort -= bufferShort[headShort];
}
else
{
countShort++;
}
bufferShort[headShort] = vol;
sumShort += vol;
headShort = (headShort + 1) % shortPeriod;
// Update long SMA
if (countLong >= longPeriod)
{
sumLong -= bufferLong[headLong];
}
else
{
countLong++;
}
bufferLong[headLong] = vol;
sumLong += vol;
headLong = (headLong + 1) % longPeriod;
// Calculate VO
double shortMa = countShort > 0 ? sumShort / countShort : vol;
double longMa = countLong > 0 ? sumLong / countLong : vol;
output[i] = longMa > 0 ? ((shortMa - longMa) / longMa) * 100.0 : 0.0;
}
}
finally
{
if (rentedShort != null)
{
System.Buffers.ArrayPool<double>.Shared.Return(rentedShort);
}
if (rentedLong != null)
{
System.Buffers.ArrayPool<double>.Shared.Return(rentedLong);
}
}
}
}
+170
View File
@@ -0,0 +1,170 @@
# VO: Volume Oscillator
> "Volume tells us the conviction behind price moves—the oscillator reveals when that conviction is accelerating or fading."
The Volume Oscillator (VO) measures the difference between two moving averages of volume, expressed as a percentage. It helps identify changes in volume trends and potential momentum shifts by comparing short-term volume activity against longer-term volume norms.
## Historical Context
Volume analysis has been a cornerstone of technical analysis since the early 20th century. Charles Dow emphasized volume as a key confirmation tool for price movements. The Volume Oscillator emerged as traders sought a normalized way to compare volume across different timeframes, similar to how price oscillators like MACD compare price moving averages.
The indicator gained popularity because raw volume numbers vary dramatically across securities and time periods. By expressing the difference between volume averages as a percentage, VO provides a consistent scale for comparison regardless of the underlying security's typical trading volume.
## Architecture & Physics
### 1. Short-Term Volume SMA
The short-term simple moving average captures recent volume activity:
$$
\text{ShortMA}_t = \frac{1}{n_s} \sum_{i=0}^{n_s-1} V_{t-i}
$$
where $n_s$ is the short period (default: 5) and $V$ is volume.
### 2. Long-Term Volume SMA
The long-term simple moving average establishes the volume baseline:
$$
\text{LongMA}_t = \frac{1}{n_l} \sum_{i=0}^{n_l-1} V_{t-i}
$$
where $n_l$ is the long period (default: 10).
### 3. Volume Oscillator Calculation
The oscillator expresses the difference as a percentage:
$$
\text{VO}_t = \frac{\text{ShortMA}_t - \text{LongMA}_t}{\text{LongMA}_t} \times 100
$$
This normalization allows:
- Positive values when short-term volume exceeds long-term average
- Negative values when short-term volume is below long-term average
- Comparable readings across different securities
### 4. Signal Line
An optional signal line smooths the VO for trend identification:
$$
\text{Signal}_t = \frac{1}{n_{sig}} \sum_{i=0}^{n_{sig}-1} \text{VO}_{t-i}
$$
where $n_{sig}$ is the signal period (default: 10).
## Mathematical Foundation
### Running Sum Implementation
For O(1) updates, we maintain running sums rather than recalculating:
$$
\text{Sum}_t = \text{Sum}_{t-1} - V_{t-n} + V_t
$$
where $V_{t-n}$ is the oldest value being removed from the window.
### Division Safety
To prevent division by zero:
$$
\text{VO}_t = \begin{cases}
\frac{\text{ShortMA}_t - \text{LongMA}_t}{\text{LongMA}_t} \times 100 & \text{if } \text{LongMA}_t > 0 \\
0 & \text{otherwise}
\end{cases}
$$
### Period Constraint
The short period must be strictly less than the long period:
$$
n_s < n_l
$$
This ensures the indicator measures the relationship between recent and historical volume, not vice versa.
## Performance Profile
### Operation Count (Streaming Mode, Scalar)
| Operation | Count | Cost (cycles) | Subtotal |
| :--- | :---: | :---: | :---: |
| ADD/SUB | 6 | 1 | 6 |
| MUL | 1 | 3 | 3 |
| DIV | 3 | 15 | 45 |
| CMP/MOD | 6 | 1 | 6 |
| **Total** | **16** | — | **~60 cycles** |
The running sum approach eliminates the need to iterate over the entire window each update.
### Memory Footprint
Per instance:
- Short buffer: $n_s \times 8$ bytes
- Long buffer: $n_l \times 8$ bytes
- Signal buffer: $n_{sig} \times 8$ bytes
- State: ~128 bytes
With defaults (5, 10, 10): ~328 bytes per instance.
### Quality Metrics
| Metric | Score | Notes |
| :--- | :---: | :--- |
| **Accuracy** | 9/10 | Exact SMA calculation |
| **Timeliness** | 7/10 | Inherent SMA lag |
| **Overshoot** | 8/10 | Bounded by percentage scale |
| **Smoothness** | 7/10 | Depends on periods chosen |
## Interpretation
### Signal Reading
| VO Value | Interpretation |
| :--- | :--- |
| **> 0** | Short-term volume above average (accumulation/distribution) |
| **< 0** | Short-term volume below average (consolidation) |
| **Rising** | Volume momentum increasing |
| **Falling** | Volume momentum decreasing |
### Trading Applications
1. **Trend Confirmation**: Rising VO during price uptrends confirms bullish momentum
2. **Divergence**: Price making new highs while VO declining suggests weakening trend
3. **Signal Crossovers**: VO crossing above signal line suggests volume momentum shift
4. **Zero-Line Crossings**: VO crossing above zero indicates short-term volume exceeding long-term average
## Validation
| Library | Status | Notes |
| :--- | :---: | :--- |
| **TA-Lib** | N/A | Not implemented |
| **Skender** | N/A | Not implemented |
| **Tulip** | N/A | Not implemented |
| **Ooples** | N/A | Not implemented |
| **PineScript** | ✅ | Reference implementation |
## Common Pitfalls
1. **Period Selection**: Short period too close to long period produces noisy signals. Recommend at least 2:1 ratio (e.g., 5 and 10, or 12 and 26).
2. **Zero Volume Handling**: Securities with occasional zero volume bars can distort calculations. Implementation uses minimum volume of 1.0 to avoid division issues.
3. **Warmup Period**: Full accuracy requires at least `longPeriod` bars. Before warmup, results use partial window averages.
4. **Percentage Interpretation**: VO of +20% means short-term volume is 20% above long-term average, not that volume increased by 20%.
5. **Signal Line Lag**: The signal line adds additional smoothing delay. For faster signals, reduce signal period or use VO directly.
6. **Bar Correction**: When using `isNew=false`, all three SMA buffers must be restored for accurate recalculation.
## References
- Murphy, J. J. (1999). *Technical Analysis of the Financial Markets*. New York Institute of Finance.
- Achelis, S. B. (2001). *Technical Analysis from A to Z*. McGraw-Hill.
- PineScript Reference: vo.pine