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