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
+308
View File
@@ -0,0 +1,308 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class VfIndicatorTests
{
[Fact]
public void VfIndicator_Constructor_SetsDefaults()
{
var indicator = new VfIndicator();
Assert.Equal("VF - Volume Force", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
Assert.Equal(14, indicator.Period);
Assert.Equal(14, indicator.MinHistoryDepths);
}
[Fact]
public void VfIndicator_ShortName_ReflectsPeriod()
{
var indicator = new VfIndicator { Period = 20 };
Assert.Equal("VF(20)", indicator.ShortName);
}
[Fact]
public void VfIndicator_MinHistoryDepths_EqualsPeriod()
{
var indicator = new VfIndicator { Period = 10 };
Assert.Equal(10, indicator.MinHistoryDepths);
Assert.Equal(10, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void VfIndicator_Period_CanBeSet()
{
var indicator = new VfIndicator { Period = 30 };
Assert.Equal(30, indicator.Period);
}
[Fact]
public void VfIndicator_Initialize_CreatesInternalVf()
{
var indicator = new VfIndicator();
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void VfIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new VfIndicator { Period = 14 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
double close = 100 + i * 0.5;
indicator.HistoricalData.AddBar(now.AddMinutes(i), close - 2, close + 2, close - 3, close, 100000);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val));
}
[Fact]
public void VfIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new VfIndicator { Period = 14 };
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);
}
[Fact]
public void VfIndicator_PriceUp_PositiveForce()
{
var indicator = new VfIndicator { Period = 14 };
indicator.Initialize();
var now = DateTime.UtcNow;
// First bar establishes baseline
indicator.HistoricalData.AddBar(now, 100, 105, 95, 100, 10000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// Second bar: close increases -> positive raw_vf
indicator.HistoricalData.AddBar(now.AddMinutes(1), 100, 110, 98, 108, 10000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(val > 0, $"VF should be positive when price increases: {val}");
}
[Fact]
public void VfIndicator_PriceDown_NegativeForce()
{
var indicator = new VfIndicator { Period = 14 };
indicator.Initialize();
var now = DateTime.UtcNow;
// First bar establishes baseline
indicator.HistoricalData.AddBar(now, 100, 105, 95, 100, 10000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// Second bar: close decreases -> negative raw_vf
indicator.HistoricalData.AddBar(now.AddMinutes(1), 100, 102, 90, 92, 10000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(val < 0, $"VF should be negative when price decreases: {val}");
}
[Fact]
public void VfIndicator_NoChange_ZeroForce()
{
var indicator = new VfIndicator { Period = 14 };
indicator.Initialize();
var now = DateTime.UtcNow;
// All bars with same close
for (int i = 0; i < 20; 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);
}
double val = indicator.LinesSeries[0].GetValue(0);
Assert.Equal(0, val, 1);
}
[Fact]
public void VfIndicator_LargerVolume_LargerImpact()
{
var indicator1 = new VfIndicator { Period = 14 };
indicator1.Initialize();
var indicator2 = new VfIndicator { Period = 14 };
indicator2.Initialize();
var now = DateTime.UtcNow;
// Same price action, different volume
for (int i = 0; i < 20; i++)
{
double close = 100 + i;
indicator1.HistoricalData.AddBar(now.AddMinutes(i), close - 2, close + 2, close - 3, close, 1000);
indicator2.HistoricalData.AddBar(now.AddMinutes(i), close - 2, close + 2, close - 3, close, 10000);
var args = i == 0
? new UpdateArgs(UpdateReason.HistoricalBar)
: new UpdateArgs(UpdateReason.NewBar);
indicator1.ProcessUpdate(args);
indicator2.ProcessUpdate(args);
}
double val1 = Math.Abs(indicator1.LinesSeries[0].GetValue(0));
double val2 = Math.Abs(indicator2.LinesSeries[0].GetValue(0));
// Higher volume should produce larger magnitude
Assert.True(val2 > val1, $"Higher volume should produce larger VF: {val2} > {val1}");
}
[Fact]
public void VfIndicator_DifferentPeriods_DifferentSmoothing()
{
var shortPeriod = new VfIndicator { Period = 5 };
shortPeriod.Initialize();
var longPeriod = new VfIndicator { Period = 30 };
longPeriod.Initialize();
var now = DateTime.UtcNow;
// Add volatile data
for (int i = 0; i < 50; i++)
{
double close = 100 + (i % 2 == 0 ? 5 : -3);
shortPeriod.HistoricalData.AddBar(now.AddMinutes(i), close - 2, close + 2, close - 3, close, 10000);
longPeriod.HistoricalData.AddBar(now.AddMinutes(i), close - 2, close + 2, close - 3, close, 10000);
var args = i == 0
? new UpdateArgs(UpdateReason.HistoricalBar)
: new UpdateArgs(UpdateReason.NewBar);
shortPeriod.ProcessUpdate(args);
longPeriod.ProcessUpdate(args);
}
double shortVal = shortPeriod.LinesSeries[0].GetValue(0);
double longVal = longPeriod.LinesSeries[0].GetValue(0);
// Different periods should produce different results
Assert.NotEqual(shortVal, longVal, 1);
}
[Fact]
public void VfIndicator_EmaSmoothing_ReducesNoise()
{
var indicator = new VfIndicator { Period = 14 };
indicator.Initialize();
var now = DateTime.UtcNow;
var values = new List<double>();
// Add noisy data
for (int i = 0; i < 30; i++)
{
// Alternating price changes
double close = 100 + (i % 2 == 0 ? 2 : -2);
indicator.HistoricalData.AddBar(now.AddMinutes(i), close - 2, close + 2, close - 3, close, 10000);
var args = i == 0
? new UpdateArgs(UpdateReason.HistoricalBar)
: new UpdateArgs(UpdateReason.NewBar);
indicator.ProcessUpdate(args);
values.Add(indicator.LinesSeries[0].GetValue(0));
}
// After warmup, values should be relatively stable (EMA smoothing)
var lastValues = values.Skip(20).ToList();
double range = lastValues.Max() - lastValues.Min();
// EMA should smooth out the alternating pattern
Assert.True(range < 100000, $"EMA should smooth values; range={range}");
}
[Fact]
public void VfIndicator_WarmupCompensation_FirstValueNotZero()
{
var indicator = new VfIndicator { Period = 14 };
indicator.Initialize();
var now = DateTime.UtcNow;
// First bar with significant price-volume action
indicator.HistoricalData.AddBar(now, 100, 110, 95, 105, 50000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// With warmup compensation, first value should not be severely damped
double firstVal = indicator.LinesSeries[0].GetValue(0);
// First bar: no previous close, so raw_vf = 0, VF = 0
// This is expected behavior for first bar
Assert.True(double.IsFinite(firstVal));
}
[Fact]
public void VfIndicator_OscillatesAroundZero()
{
var indicator = new VfIndicator { Period = 14 };
indicator.Initialize();
var now = DateTime.UtcNow;
bool hasPositive = false;
bool hasNegative = false;
// Mix of up and down days
for (int i = 0; i < 50; i++)
{
double close = 100 + Math.Sin(i * 0.5) * 10;
indicator.HistoricalData.AddBar(now.AddMinutes(i), close - 2, close + 2, close - 3, close, 10000);
var args = i == 0
? new UpdateArgs(UpdateReason.HistoricalBar)
: new UpdateArgs(UpdateReason.NewBar);
indicator.ProcessUpdate(args);
double val = indicator.LinesSeries[0].GetValue(0);
if (val > 0)
{
hasPositive = true;
}
if (val < 0)
{
hasNegative = true;
}
}
Assert.True(hasPositive && hasNegative, "VF should oscillate around zero");
}
}
+53
View File
@@ -0,0 +1,53 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class VfIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 10, minimum: 1, maximum: 1000, increment: 1)]
public int Period { get; set; } = 14;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Vf _vf = null!;
private readonly LineSeries _series;
#pragma warning disable S2325 // Instance property required by Quantower indicator interface
public int MinHistoryDepths => Period;
#pragma warning restore S2325
int IWatchlistIndicator.MinHistoryDepths => Period;
public override string ShortName => $"VF({Period})";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/volume/vf/Vf.Quantower.cs";
public VfIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "VF - Volume Force";
Description = "Measures the force of volume behind price movements using EMA smoothing with warmup compensation.";
_series = new LineSeries(name: "VF", color: Color.Magenta, width: 2, style: LineStyle.Solid);
AddLineSeries(_series);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_vf = new Vf(Period);
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
TBar bar = this.GetInputBar(args);
TValue result = _vf.Update(bar, args.IsNewBar());
_series.SetValue(result.Value, _vf.IsHot, ShowColdValues);
}
}
+589
View File
@@ -0,0 +1,589 @@
using Xunit;
namespace QuanTAlib.Tests;
public class VfTests
{
private const double Tolerance = 1e-10;
private const int DefaultPeriod = 14;
#region Constructor Tests
[Fact]
public void Constructor_DefaultPeriod_SetsCorrectProperties()
{
var vf = new Vf();
Assert.Equal("Vf(14)", vf.Name);
Assert.Equal(14, vf.WarmupPeriod);
Assert.False(vf.IsHot);
}
[Fact]
public void Constructor_CustomPeriod_SetsCorrectProperties()
{
var vf = new Vf(period: 20);
Assert.Equal("Vf(20)", vf.Name);
Assert.Equal(20, vf.WarmupPeriod);
}
[Fact]
public void Constructor_PeriodLessThanOne_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Vf(period: 0));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Constructor_NegativePeriod_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Vf(period: -5));
Assert.Equal("period", ex.ParamName);
}
#endregion
#region Basic Calculation Tests
[Fact]
public void Update_FirstBar_ReturnsZero()
{
var vf = new Vf();
var bar = new TBar(DateTime.UtcNow, 100, 105, 95, 102, 1000);
var result = vf.Update(bar);
Assert.Equal(0, result.Value);
}
[Fact]
public void Update_PriceIncrease_ReturnsPositiveValue()
{
var vf = new Vf();
var time = DateTime.UtcNow;
vf.Update(new TBar(time, 100, 105, 95, 100, 1000));
var result = vf.Update(new TBar(time.AddMinutes(1), 100, 110, 98, 105, 2000)); // +5 price change
Assert.True(result.Value > 0, "VF should be positive when price increases");
}
[Fact]
public void Update_PriceDecrease_ReturnsNegativeValue()
{
var vf = new Vf();
var time = DateTime.UtcNow;
vf.Update(new TBar(time, 100, 105, 95, 100, 1000));
var result = vf.Update(new TBar(time.AddMinutes(1), 100, 102, 90, 95, 2000)); // -5 price change
Assert.True(result.Value < 0, "VF should be negative when price decreases");
}
[Fact]
public void Update_NoPriceChange_ReturnsZeroOrNearZero()
{
var vf = new Vf();
var time = DateTime.UtcNow;
vf.Update(new TBar(time, 100, 105, 95, 100, 1000));
var result = vf.Update(new TBar(time.AddMinutes(1), 100, 105, 95, 100, 2000)); // 0 price change
Assert.Equal(0, result.Value, Tolerance);
}
[Fact]
public void Update_ReturnsCorrectTime()
{
var vf = new Vf();
var expectedTime = DateTime.UtcNow;
var bar = new TBar(expectedTime, 100, 105, 95, 102, 1000);
var result = vf.Update(bar);
Assert.Equal(expectedTime.Ticks, result.Time);
}
#endregion
#region Formula Verification Tests
[Fact]
public void Update_SecondBar_AppliesEmaWithWarmupCompensation()
{
var vf = new Vf(period: 10);
var time = DateTime.UtcNow;
// First bar
vf.Update(new TBar(time, 100, 105, 95, 100, 1000));
// Second bar: price change = 110 - 100 = 10, raw_vf = 10 * 2000 = 20000
var result = vf.Update(new TBar(time.AddMinutes(1), 108, 115, 105, 110, 2000));
// Expected: ~20000 (the warmup compensation should give us the raw value initially)
Assert.True(Math.Abs(result.Value - 20000) < 1, "VF should be approximately 20000 with warmup compensation");
}
[Fact]
public void Update_MultipleBarSequence_CalculatesCorrectly()
{
var vf = new Vf(period: 3);
var time = DateTime.UtcNow;
// Bar 1: establishes baseline
vf.Update(new TBar(time, 100, 105, 95, 100, 1000));
// Bar 2: price +10, volume 1000 -> raw_vf = 10000
vf.Update(new TBar(time.AddMinutes(1), 100, 115, 98, 110, 1000));
// Bar 3: price -5, volume 500 -> raw_vf = -2500
vf.Update(new TBar(time.AddMinutes(2), 108, 112, 103, 105, 500));
// Bar 4: price +5, volume 2000 -> raw_vf = 10000
var result = vf.Update(new TBar(time.AddMinutes(3), 105, 115, 104, 110, 2000));
// Result should be a smoothed positive value (EMA of 10000, -2500, 10000)
Assert.True(result.Value > 0, "VF should be positive given more positive raw_vf values");
}
#endregion
#region IsHot Tests
[Fact]
public void IsHot_BeforeWarmup_ReturnsFalse()
{
var vf = new Vf(period: 5);
var time = DateTime.UtcNow;
for (int i = 0; i < 4; i++)
{
vf.Update(new TBar(time.AddMinutes(i), 100 + i, 105 + i, 95 + i, 102 + i, 1000));
}
Assert.False(vf.IsHot);
}
[Fact]
public void IsHot_AtWarmup_ReturnsTrue()
{
var vf = new Vf(period: 5);
var time = DateTime.UtcNow;
for (int i = 0; i < 5; i++)
{
vf.Update(new TBar(time.AddMinutes(i), 100 + i, 105 + i, 95 + i, 102 + i, 1000));
}
Assert.True(vf.IsHot);
}
[Fact]
public void IsHot_AfterWarmup_ReturnsTrue()
{
var vf = new Vf(period: 5);
var time = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
vf.Update(new TBar(time.AddMinutes(i), 100 + i, 105 + i, 95 + i, 102 + i, 1000));
}
Assert.True(vf.IsHot);
}
#endregion
#region Bar Correction (isNew=false) Tests
[Fact]
public void Update_IsNewFalse_RollsBackState()
{
var vf = new Vf();
var time = DateTime.UtcNow;
vf.Update(new TBar(time, 100, 105, 95, 100, 1000));
var valueAfterFirst = vf.Update(new TBar(time.AddMinutes(1), 100, 110, 98, 105, 2000));
// Update same bar with different data (isNew=false)
var valueAfterCorrection = vf.Update(new TBar(time.AddMinutes(1), 100, 108, 96, 103, 1500), isNew: false);
// Values should differ because the bar was corrected
Assert.NotEqual(valueAfterFirst.Value, valueAfterCorrection.Value);
}
[Fact]
public void Update_MultipleCorrections_MaintainsConsistency()
{
var vf = new Vf();
var time = DateTime.UtcNow;
vf.Update(new TBar(time, 100, 105, 95, 100, 1000));
// First update
vf.Update(new TBar(time.AddMinutes(1), 100, 110, 98, 105, 2000));
// Multiple corrections
vf.Update(new TBar(time.AddMinutes(1), 100, 108, 96, 103, 1500), isNew: false);
vf.Update(new TBar(time.AddMinutes(1), 100, 112, 97, 108, 2500), isNew: false);
var finalValue = vf.Update(new TBar(time.AddMinutes(1), 100, 110, 98, 105, 2000), isNew: false);
// Final correction back to original should match
vf.Reset();
vf.Update(new TBar(time, 100, 105, 95, 100, 1000));
var expectedValue = vf.Update(new TBar(time.AddMinutes(1), 100, 110, 98, 105, 2000));
Assert.Equal(expectedValue.Value, finalValue.Value, Tolerance);
}
[Fact]
public void Update_IterativeCorrections_RestoreOriginalState()
{
var vf = new Vf();
var time = DateTime.UtcNow;
// Build up state
vf.Update(new TBar(time, 100, 105, 95, 100, 1000));
vf.Update(new TBar(time.AddMinutes(1), 100, 110, 98, 105, 2000));
var originalValue = vf.Update(new TBar(time.AddMinutes(2), 105, 115, 103, 110, 1500));
// Make correction
vf.Update(new TBar(time.AddMinutes(2), 105, 120, 100, 115, 3000), isNew: false);
// Restore original
var restoredValue = vf.Update(new TBar(time.AddMinutes(2), 105, 115, 103, 110, 1500), isNew: false);
Assert.Equal(originalValue.Value, restoredValue.Value, Tolerance);
}
#endregion
#region Reset Tests
[Fact]
public void Reset_ClearsState()
{
var vf = new Vf();
var time = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
vf.Update(new TBar(time.AddMinutes(i), 100 + i, 105 + i, 95 + i, 102 + i, 1000));
}
vf.Reset();
Assert.False(vf.IsHot);
Assert.Equal(default, vf.Last);
}
[Fact]
public void Reset_AllowsReuse()
{
var vf = new Vf();
var time = DateTime.UtcNow;
// First use
vf.Update(new TBar(time, 100, 105, 95, 100, 1000));
var firstResult = vf.Update(new TBar(time.AddMinutes(1), 100, 110, 98, 105, 2000));
vf.Reset();
// Second use with same data
vf.Update(new TBar(time, 100, 105, 95, 100, 1000));
var secondResult = vf.Update(new TBar(time.AddMinutes(1), 100, 110, 98, 105, 2000));
Assert.Equal(firstResult.Value, secondResult.Value, Tolerance);
}
#endregion
#region NaN/Infinity Handling Tests
[Fact]
public void Update_NaNClose_UsesLastValidValue()
{
var vf = new Vf();
var time = DateTime.UtcNow;
vf.Update(new TBar(time, 100, 105, 95, 100, 1000));
_ = vf.Update(new TBar(time.AddMinutes(1), 100, 110, 98, 105, 2000));
// Update with NaN close
var nanResult = vf.Update(new TBar(time.AddMinutes(2), 105, 115, 100, double.NaN, 1500));
Assert.True(double.IsFinite(nanResult.Value), "VF should handle NaN close gracefully");
}
[Fact]
public void Update_NaNVolume_UsesLastValidValue()
{
var vf = new Vf();
var time = DateTime.UtcNow;
vf.Update(new TBar(time, 100, 105, 95, 100, 1000));
vf.Update(new TBar(time.AddMinutes(1), 100, 110, 98, 105, 2000));
// Update with NaN volume
var result = vf.Update(new TBar(time.AddMinutes(2), 105, 115, 100, 110, double.NaN));
Assert.True(double.IsFinite(result.Value), "VF should handle NaN volume gracefully");
}
[Fact]
public void Update_InfinityInput_UsesLastValidValue()
{
var vf = new Vf();
var time = DateTime.UtcNow;
vf.Update(new TBar(time, 100, 105, 95, 100, 1000));
vf.Update(new TBar(time.AddMinutes(1), 100, 110, 98, 105, 2000));
// Update with infinity
var result = vf.Update(new TBar(time.AddMinutes(2), 105, 115, 100, double.PositiveInfinity, 1500));
Assert.True(double.IsFinite(result.Value), "VF should handle infinity gracefully");
}
#endregion
#region Event Tests
[Fact]
public void Update_PublishesEvent()
{
var vf = new Vf();
TValue? receivedValue = null;
bool? receivedIsNew = null;
vf.Pub += (object? sender, in TValueEventArgs args) =>
{
receivedValue = args.Value;
receivedIsNew = args.IsNew;
};
var bar = new TBar(DateTime.UtcNow, 100, 105, 95, 102, 1000);
var result = vf.Update(bar);
Assert.NotNull(receivedValue);
Assert.Equal(result.Value, receivedValue.Value.Value);
Assert.True(receivedIsNew);
}
[Fact]
public void Update_IsNewFalse_PublishesEventWithIsNewFalse()
{
var vf = new Vf();
var time = DateTime.UtcNow;
vf.Update(new TBar(time, 100, 105, 95, 100, 1000));
bool? receivedIsNew = null;
vf.Pub += (object? sender, in TValueEventArgs args) => receivedIsNew = args.IsNew;
vf.Update(new TBar(time.AddMinutes(1), 100, 110, 98, 105, 2000), isNew: false);
Assert.False(receivedIsNew);
}
#endregion
#region Batch Mode Tests
[Fact]
public void Update_TBarSeries_ReturnsCorrectLength()
{
var vf = new Vf();
var series = GenerateTestBarSeries(100);
var result = vf.Update(series);
Assert.Equal(100, result.Count);
}
[Fact]
public void Calculate_TBarSeries_ReturnsCorrectLength()
{
var series = GenerateTestBarSeries(100);
var result = Vf.Calculate(series, DefaultPeriod);
Assert.Equal(100, result.Count);
}
[Fact]
public void Calculate_EmptySeries_ReturnsEmpty()
{
var series = new TBarSeries();
var result = Vf.Calculate(series, DefaultPeriod);
Assert.Empty(result);
}
#endregion
#region Span Mode Tests
[Fact]
public void Calculate_Span_MatchesStreamingMode()
{
var series = GenerateTestBarSeries(50);
var close = new double[50];
var volume = new double[50];
var output = new double[50];
// Extract values from series
for (int i = 0; i < 50; i++)
{
close[i] = series[i].Close;
volume[i] = series[i].Volume;
}
// Span calculation
Vf.Calculate(close, volume, output, DefaultPeriod);
// Streaming calculation
var vf = new Vf(DefaultPeriod);
var streamingResult = vf.Update(series);
// Compare last 30 values (after warmup)
for (int i = 20; i < 50; i++)
{
Assert.Equal(streamingResult[i].Value, output[i], Tolerance);
}
}
[Fact]
public void Calculate_Span_MismatchedLengths_ThrowsArgumentException()
{
var close = new double[100];
var volume = new double[50]; // Different length
var output = new double[100];
var ex = Assert.Throws<ArgumentException>(() => Vf.Calculate(close, volume, output, DefaultPeriod));
Assert.Equal("volume", ex.ParamName);
}
[Fact]
public void Calculate_Span_OutputLengthMismatch_ThrowsArgumentException()
{
var close = new double[100];
var volume = new double[100];
var output = new double[50]; // Different length
var ex = Assert.Throws<ArgumentException>(() => Vf.Calculate(close, volume, output, DefaultPeriod));
Assert.Equal("output", ex.ParamName);
}
[Fact]
public void Calculate_Span_InvalidPeriod_ThrowsArgumentException()
{
var close = new double[100];
var volume = new double[100];
var output = new double[100];
var ex = Assert.Throws<ArgumentException>(() => Vf.Calculate(close, volume, output, period: 0));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Calculate_Span_EmptyInput_ReturnsWithoutError()
{
var close = Array.Empty<double>();
var volume = Array.Empty<double>();
var output = Array.Empty<double>();
// Should not throw
Vf.Calculate(close, volume, output, DefaultPeriod);
Assert.True(true); // Test passes if no exception
}
[Fact]
public void Calculate_Span_FirstValueIsZero()
{
var close = new double[] { 100, 105, 110, 108, 112 };
var volume = new double[] { 1000, 2000, 1500, 1800, 2200 };
var output = new double[5];
Vf.Calculate(close, volume, output, period: 3);
Assert.Equal(0, output[0]);
}
#endregion
#region TValue Update Tests
[Fact]
public void Update_TValue_ThrowsNotSupportedException()
{
var vf = new Vf();
var time = DateTime.UtcNow;
// Build up state with bars
vf.Update(new TBar(time, 100, 105, 95, 100, 1000));
vf.Update(new TBar(time.AddMinutes(1), 100, 110, 98, 105, 2000));
// Update with TValue should throw NotSupportedException (VF requires volume)
var ex = Assert.Throws<NotSupportedException>(() => vf.Update(new TValue(time.AddMinutes(2), 110)));
Assert.Contains("volume", ex.Message, StringComparison.OrdinalIgnoreCase);
}
#endregion
#region Mode Consistency Tests
[Fact]
public void AllModes_ProduceSameResults()
{
var series = GenerateTestBarSeries(100);
var close = new double[100];
var volume = new double[100];
// Extract values from series
for (int i = 0; i < 100; i++)
{
close[i] = series[i].Close;
volume[i] = series[i].Volume;
}
// Streaming mode
var vf = new Vf(DefaultPeriod);
var streamingResult = vf.Update(series);
// Batch mode
var batchResult = Vf.Calculate(series, DefaultPeriod);
// Span mode
var spanOutput = new double[100];
Vf.Calculate(close, volume, spanOutput, DefaultPeriod);
// Compare all modes (last 50 values to avoid warmup differences)
for (int i = 50; i < 100; i++)
{
Assert.Equal(streamingResult[i].Value, batchResult[i].Value, Tolerance);
Assert.Equal(streamingResult[i].Value, spanOutput[i], Tolerance);
}
}
#endregion
#region Helper Methods
private static TBarSeries GenerateTestBarSeries(int count)
{
var bars = new TBarSeries();
var gbm = new GBM(seed: 42);
for (int i = 0; i < count; i++)
{
bars.Add(gbm.Next());
}
return bars;
}
#endregion
}
+319
View File
@@ -0,0 +1,319 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// VF: Volume Force
/// Measures the force of volume behind price movements by multiplying price change
/// by volume and applying EMA smoothing with warmup compensation.
/// </summary>
/// <remarks>
/// VF Formula:
/// price_change = Close - Previous Close
/// raw_vf = price_change × Volume
/// VF = EMA(raw_vf, period) with warmup compensation
///
/// Warmup compensation:
/// e *= (1 - alpha)
/// compensator = 1 / (1 - e)
/// VF = compensator × EMA during warmup phase
///
/// Key characteristics:
/// - Positive when price is rising with volume
/// - Negative when price is falling with volume
/// - EMA smoothing reduces noise
/// - Warmup compensation prevents initial bias
///
/// Sources:
/// PineScript reference: vf.pine
/// </remarks>
[SkipLocalsInit]
public sealed class Vf : ITValuePublisher
{
[StructLayout(LayoutKind.Auto)]
private record struct State(
double EmaValue,
double E,
double PrevClose,
double LastValidClose,
double LastValidVolume,
bool Warmup,
int Index);
private State _s;
private State _ps;
private readonly int _period;
private readonly double _alpha;
/// <inheritdoc/>
public TValue Last { get; private set; }
/// <inheritdoc/>
public bool IsHot => _s.Index >= _period;
/// <inheritdoc/>
public int WarmupPeriod => _period;
/// <inheritdoc/>
public string Name { get; }
/// <inheritdoc/>
public event TValuePublishedHandler? Pub;
/// <summary>
/// Initializes a new instance of the VF indicator.
/// </summary>
/// <param name="period">The smoothing period (default: 14).</param>
/// <exception cref="ArgumentException">Thrown when period is less than 1.</exception>
public Vf(int period = 14)
{
if (period < 1)
{
throw new ArgumentException("Period must be at least 1", nameof(period));
}
_period = period;
_alpha = 2.0 / (period + 1);
Name = $"Vf({period})";
Reset();
}
/// <summary>
/// Resets the indicator to its initial state.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Reset()
{
_s = new State(EmaValue: 0, E: 1, PrevClose: 0, LastValidClose: 0, LastValidVolume: 0, Warmup: true, Index: 0);
_ps = _s;
Last = default;
}
/// <summary>
/// Updates the VF 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 VF value.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TBar input, bool isNew = true)
{
if (isNew)
{
_ps = _s;
}
else
{
_s = _ps;
}
var s = _s;
// Handle NaN/Infinity - substitute with last valid values
double close = double.IsFinite(input.Close) ? input.Close : s.LastValidClose;
double volume = double.IsFinite(input.Volume) ? input.Volume : s.LastValidVolume;
// Update last valid values
if (double.IsFinite(input.Close) && input.Close > 0)
{
s.LastValidClose = input.Close;
}
if (double.IsFinite(input.Volume) && input.Volume >= 0)
{
s.LastValidVolume = input.Volume;
}
double vfResult;
if (s.Index == 0)
{
// First bar: no previous close, raw_vf = 0
s.PrevClose = close;
s.EmaValue = 0;
vfResult = 0;
}
else
{
// Calculate price change and raw VF
double priceChange = close - s.PrevClose;
double rawVf = priceChange * volume;
// Update EMA: ema = alpha * (raw - ema) + ema = alpha * raw + (1 - alpha) * ema
s.EmaValue = Math.FusedMultiplyAdd(_alpha, rawVf - s.EmaValue, s.EmaValue);
// Apply warmup compensation
if (s.Warmup)
{
s.E *= (1.0 - _alpha);
double compensator = 1.0 / (1.0 - s.E);
vfResult = compensator * s.EmaValue;
s.Warmup = s.E > 1e-10;
}
else
{
vfResult = s.EmaValue;
}
// Store for next iteration
s.PrevClose = close;
}
if (isNew)
{
s.Index++;
}
_s = s;
Last = new TValue(input.Time, vfResult);
Pub?.Invoke(this, new TValueEventArgs { Value = Last, IsNew = isNew });
return Last;
}
/// <summary>
/// Updates the VF with a TValue input.
/// </summary>
/// <remarks>
/// VF requires volume data for proper calculation. This method throws NotSupportedException
/// because TValue does not contain volume information. Use Update(TBar) instead.
/// </remarks>
/// <exception cref="NotSupportedException">Always thrown because VF requires volume data.</exception>
#pragma warning disable S2325 // Method signature must match ITValuePublisher contract
public TValue Update(TValue input, bool isNew = true)
#pragma warning restore S2325
{
// VF requires volume; TValue does not contain volume, so this operation is not supported
throw new NotSupportedException("VF requires volume data. Use Update(TBar) instead of Update(TValue).");
}
/// <summary>
/// Updates the VF 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 VF for a series of bars (static batch mode).
/// </summary>
/// <param name="source">The bar series.</param>
/// <param name="period">The smoothing period (default: 14).</param>
/// <returns>The result series.</returns>
public static TSeries Calculate(TBarSeries source, int period = 14)
{
if (source.Count == 0)
{
return [];
}
var t = source.Open.Times.ToArray();
var v = new double[source.Count];
Calculate(source.Close.Values, source.Volume.Values, v, period);
return new TSeries(t, v);
}
/// <summary>
/// Calculates VF for spans of close and volume data (high-performance span mode).
/// </summary>
/// <param name="close">The close price span.</param>
/// <param name="volume">The volume span.</param>
/// <param name="output">The output VF span.</param>
/// <param name="period">The smoothing period (default: 14).</param>
/// <exception cref="ArgumentException">Thrown when span lengths don't match or period is invalid.</exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Calculate(ReadOnlySpan<double> close, ReadOnlySpan<double> volume, Span<double> output, int period = 14)
{
if (period < 1)
{
throw new ArgumentException("Period must be at least 1", nameof(period));
}
if (close.Length != volume.Length)
{
throw new ArgumentException("Close and Volume spans must be of the same length", nameof(volume));
}
if (close.Length != output.Length)
{
throw new ArgumentException("Output span must be of the same length as input", nameof(output));
}
int len = close.Length;
if (len == 0)
{
return;
}
double alpha = 2.0 / (period + 1);
double emaValue = 0;
double e = 1.0;
bool warmup = true;
double lastValidClose = close[0];
double lastValidVolume = volume[0];
// First bar: no previous close, VF = 0
output[0] = 0;
double prevClose = double.IsFinite(close[0]) ? close[0] : 0;
if (double.IsFinite(close[0]) && close[0] > 0)
{
lastValidClose = close[0];
}
if (double.IsFinite(volume[0]) && volume[0] >= 0)
{
lastValidVolume = volume[0];
}
for (int i = 1; i < len; i++)
{
// Get valid values
double c = double.IsFinite(close[i]) ? close[i] : lastValidClose;
double v = double.IsFinite(volume[i]) ? volume[i] : lastValidVolume;
// Update last valid values
if (double.IsFinite(close[i]) && close[i] > 0)
{
lastValidClose = close[i];
}
if (double.IsFinite(volume[i]) && volume[i] >= 0)
{
lastValidVolume = volume[i];
}
// Calculate price change and raw VF
double priceChange = c - prevClose;
double rawVf = priceChange * v;
// Update EMA
emaValue = Math.FusedMultiplyAdd(alpha, rawVf - emaValue, emaValue);
double vfResult;
if (warmup)
{
e *= (1.0 - alpha);
double compensator = 1.0 / (1.0 - e);
vfResult = compensator * emaValue;
warmup = e > 1e-10;
}
else
{
vfResult = emaValue;
}
output[i] = vfResult;
prevClose = c;
}
}
}
+283
View File
@@ -0,0 +1,283 @@
# VF: Volume Force
> "Price without volume is like a punch without body weight behind it—VF measures the momentum of conviction." — Anonymous Quant
Volume Force (VF) quantifies the strength of volume behind price movements by multiplying price change by volume and applying EMA smoothing with warmup compensation. The result is a momentum-style oscillator that distinguishes between genuine volume-backed moves and hollow price action.
Unlike simple volume indicators that ignore direction, VF combines directional price change with volume intensity. Large volumes during significant price moves produce high VF readings; large volumes during flat price action contribute nothing. This selectivity makes VF particularly effective at filtering noise from signal.
## Historical Context
Volume Force derives from the concept of "Force Index" popularized by Alexander Elder in his 1993 book "Trading for a Living." Elder's original Force Index multiplied price change by volume without smoothing:
$$
Force_t = (Close_t - Close_{t-1}) \times Volume_t
$$
VF enhances this concept with EMA smoothing and warmup compensation, addressing two limitations of the raw Force Index:
1. **Noise sensitivity**: Raw Force Index is extremely volatile
2. **Initial bias**: Standard EMA starts with zero, creating warmup distortion
The warmup compensation technique ensures that early VF values aren't biased toward zero, providing accurate readings from the second bar onward. This makes VF suitable for both long-term trending analysis and short-term momentum assessment.
## Architecture & Physics
VF combines three components: price change calculation, volume weighting, and EMA smoothing with compensation.
### Component Breakdown
1. **Price Change**: Difference between current and previous close
2. **Raw VF**: Price change multiplied by volume (Force Index)
3. **EMA Smoothing**: Exponential moving average of raw VF
4. **Warmup Compensation**: Bias correction during initial period
### State Requirements
| Component | Type | Purpose |
| :--- | :--- | :--- |
| EmaValue | double | Smoothed VF value |
| E | double | Warmup decay factor (starts at 1) |
| PrevClose | double | Previous bar's close price |
| LastValidClose | double | Fallback for NaN handling |
| LastValidVolume | double | Fallback for NaN handling |
| Warmup | bool | Whether compensation is active |
| Index | int | Bar counter for IsHot |
### Warmup Compensation Mechanism
Standard EMA initialization biases early values toward zero:
$$
EMA_1 = \alpha \times Value_1 + (1 - \alpha) \times 0 = \alpha \times Value_1
$$
This underestimates the true average. VF compensates by tracking the decay factor:
$$
e_t = e_{t-1} \times (1 - \alpha)
$$
$$
VF_t = \frac{EMA_t}{1 - e_t}
$$
As $e \rightarrow 0$, the compensator $\frac{1}{1 - e} \rightarrow 1$, and VF converges to the raw EMA.
## Mathematical Foundation
### Core Formula
$$
PriceChange_t = Close_t - Close_{t-1}
$$
$$
RawVF_t = PriceChange_t \times Volume_t
$$
$$
EMA_t = \alpha \times RawVF_t + (1 - \alpha) \times EMA_{t-1}
$$
where $\alpha = \frac{2}{period + 1}$
### With Warmup Compensation
$$
e_t = e_{t-1} \times (1 - \alpha), \quad e_0 = 1
$$
$$
VF_t = \begin{cases}
\frac{EMA_t}{1 - e_t} & \text{if } e_t > 10^{-10} \\
EMA_t & \text{otherwise}
\end{cases}
$$
### First Bar Handling
The first bar has no previous close, so:
$$
VF_0 = 0
$$
This is mathematically correct—there's no price change to measure.
### FMA Optimization
The EMA update uses fused multiply-add for numerical precision:
```csharp
emaValue = Math.FusedMultiplyAdd(alpha, rawVf - emaValue, emaValue);
// Equivalent to: emaValue = alpha * (rawVf - emaValue) + emaValue
// Which equals: emaValue = alpha * rawVf + (1 - alpha) * emaValue
```
## Performance Profile
### Operation Count (Streaming Mode)
| Operation | Count | Notes |
| :--- | :---: | :--- |
| SUB | 2 | Price change, EMA diff |
| MUL | 3 | Raw VF, EMA decay, compensation |
| ADD | 1 | FMA operation |
| DIV | 1 | Compensation factor |
| CMP | 1 | Warmup check |
| **Total** | 8 | Per bar, O(1) |
### Batch Mode (SIMD)
| Operation | Vectorizable | Notes |
| :--- | :---: | :--- |
| Price differences | ✅ | Parallel subtraction |
| Volume multiplication | ✅ | Parallel multiply |
| EMA recursion | ❌ | Sequential dependency |
| Compensation | ❌ | Depends on EMA state |
The EMA recursion prevents full SIMD optimization. However, the price × volume multiplication can be vectorized before the sequential EMA pass.
### Memory Footprint
| Scope | Size |
| :--- | :--- |
| Per instance | ~112 bytes (State record struct × 2) |
| Buffer requirements | None (O(1) state) |
### Quality Metrics
| Metric | Score | Notes |
| :--- | :---: | :--- |
| **Accuracy** | 10/10 | FMA-precise computation |
| **Timeliness** | 9/10 | Second bar valid; warmup compensated |
| **Smoothness** | 8/10 | EMA provides controlled smoothing |
| **Noise Filtering** | 7/10 | Period-dependent noise reduction |
| **Memory** | 10/10 | O(1) constant |
## Validation
| Library | Status | Notes |
| :--- | :---: | :--- |
| **TA-Lib** | N/A | Has Force Index but no VF variant |
| **Skender** | N/A | Not implemented |
| **Tulip** | N/A | Not implemented |
| **Ooples** | N/A | Not implemented |
| **PineScript** | ✅ | Reference implementation (vf.pine) |
VF validation focuses on internal consistency between streaming, batch, and span modes (verified with 1e-10 tolerance) and formula correctness against manual calculations.
## Common Pitfalls
1. **First Bar Is Always Zero**: VF requires a previous close to compute price change. The first bar returns 0 regardless of volume. This is correct behavior, not a bug.
2. **Period Selection**: Shorter periods (5-10) respond quickly but are noisy. Longer periods (20-50) smooth heavily but lag. Default of 14 balances responsiveness and smoothness.
3. **Scale Interpretation**: VF values are in "volume × price" units. A VF of 100,000 means different things for different instruments. Focus on direction and relative magnitude rather than absolute values.
4. **Zero Crossings**: VF oscillates around zero. Positive values indicate net buying pressure; negative indicates selling. Zero crossings can signal momentum shifts but generate noise in ranging markets.
5. **Volume Spikes**: Extreme volume events (earnings, news) can create VF spikes that distort the EMA. Consider whether such events should inform your analysis or be filtered.
6. **Warmup Period**: While warmup compensation provides accurate early values, IsHot only becomes true after `period` bars. This matches EMA convention for statistical significance.
7. **NaN Handling**: VF substitutes last valid values for NaN/Infinity inputs. This maintains continuity but can mask data quality issues. Monitor your data feed.
8. **isNew Parameter**: Bar correction (isNew = false) properly restores EMA state including the warmup decay factor. Incorrect usage corrupts the smoothing calculation.
## Interpretation Guide
### Momentum Analysis
| VF Value | Volume | Price Move | Interpretation |
| :--- | :--- | :--- | :--- |
| Large positive | High | Up | Strong buying pressure |
| Small positive | Low | Up | Weak buying pressure |
| Large negative | High | Down | Strong selling pressure |
| Small negative | Low | Down | Weak selling pressure |
| Near zero | Any | Flat | No directional conviction |
### Divergence Signals
VF divergences often precede price reversals:
1. **Bullish divergence**: Price makes lower low, VF makes higher low
- Selling pressure is weakening despite lower prices
- Potential reversal to upside
2. **Bearish divergence**: Price makes higher high, VF makes lower high
- Buying pressure is weakening despite higher prices
- Potential reversal to downside
### Zero Line Crossings
| Crossing | Direction | Signal |
| :--- | :--- | :--- |
| Below → Above | Bullish | Net buying pressure emerges |
| Above → Below | Bearish | Net selling pressure emerges |
Filter zero crossings in ranging markets—they generate excessive signals without follow-through.
### Trend Confirmation
Use VF to confirm price trends:
- **Uptrend**: VF should stay predominantly positive
- **Downtrend**: VF should stay predominantly negative
- **Healthy trend**: VF pullbacks don't cross zero deeply
### Volume-Weighted Momentum
Compare VF to simple price momentum:
| VF vs Price Momentum | Interpretation |
| :--- | :--- |
| VF confirms | Volume supports the move |
| VF diverges | Volume doesn't support—potential reversal |
| VF leads | Volume commitment precedes price |
| VF lags | Volume follows price—chasing behavior |
## Parameter Selection Guide
| Period | Character | Use Case |
| :--- | :--- | :--- |
| 5-7 | Very responsive | Scalping, intraday momentum |
| 10-14 | Balanced | Swing trading (default: 14) |
| 20-30 | Smooth | Position trading |
| 50+ | Very smooth | Trend identification |
### Period vs Responsiveness Trade-off
$$
\alpha = \frac{2}{period + 1}
$$
| Period | α | Half-life (bars) |
| :--- | :--- | :--- |
| 5 | 0.333 | ~2.4 |
| 10 | 0.182 | ~5.5 |
| 14 | 0.133 | ~8.0 |
| 20 | 0.095 | ~12.0 |
| 50 | 0.039 | ~31.0 |
Half-life indicates how many bars until a spike decays to half its initial impact.
## Comparison with Related Indicators
| Indicator | Formula | Smoothing | Normalization |
| :--- | :--- | :--- | :--- |
| **VF** | ΔP × V, EMA smoothed | Yes (period) | None |
| **Force Index** | ΔP × V | None (raw) | None |
| **OBV** | Cumulative ±V | None | None |
| **MFI** | Money Flow Ratio | Period lookback | 0-100 |
| **CMF** | AD / Volume | Period average | -1 to +1 |
VF occupies a middle ground: more responsive than OBV/CMF (not cumulative), smoother than raw Force Index, unbounded unlike MFI.
## References
- Elder, A. (1993). "Trading for a Living." John Wiley & Sons.
- Ehlers, J. (2001). "Rocket Science for Traders." John Wiley & Sons.
- Murphy, J. (1999). "Technical Analysis of the Financial Markets." New York Institute of Finance.
- TradingView. "PineScript Volume Force." Community Reference.