mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-23 13:08:04 +00:00
volume indicators
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user