Files
QuanTAlib/lib/volume/pvi/Pvi.cs
T
Miha Kralj 76d2b50cbb Add Price Volume Trend (PVT) Indicator and Tests
- Implemented the PvtIndicator class for calculating Price Volume Trend in Quantower.
- Created unit tests for the Pvt class to validate calculations and state management.
- Added validation tests to ensure consistency with OoplesFinance's implementation.
- Developed a comprehensive documentation (Pvt.md) explaining the PVT concept, calculations, and usage.
- Included methods for batch calculations and streaming updates for PVT.
2026-01-28 17:54:43 -08:00

285 lines
8.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// PVI: Positive Volume Index
/// </summary>
/// <remarks>
/// Positive Volume Index tracks price changes on days when volume increases compared
/// to the previous day. The theory is that on high-volume days, the "uninformed crowd"
/// is trading, while low-volume days are driven by smart money (institutional investors).
///
/// Calculation:
/// - If Volume &gt; Previous Volume: PVI = Previous PVI × (Close / Previous Close)
/// - If Volume &lt;= Previous Volume: PVI = Previous PVI (unchanged)
/// - Typically starts at 100 or 1000
///
/// PVI is often used with its signal line (a moving average of PVI) to generate
/// buy/sell signals. When PVI is below its 1-year moving average, there is a 67%
/// probability of a bear market according to Fosback.
///
/// Sources:
/// https://www.investopedia.com/terms/p/pvi.asp
/// https://school.stockcharts.com/doku.php?id=technical_indicators:positive_volume_index
/// </remarks>
[SkipLocalsInit]
public sealed class Pvi : ITValuePublisher
{
private readonly double _startValue;
[StructLayout(LayoutKind.Auto)]
private record struct State(
double PviValue,
double PrevClose,
double PrevVolume,
double LastValidClose,
double LastValidVolume,
int Index);
private State _s;
private State _ps;
/// <summary>
/// Display name for the indicator.
/// </summary>
public string Name { get; }
public event TValuePublishedHandler? Pub;
/// <summary>
/// Current PVI value.
/// </summary>
public TValue Last { get; private set; }
/// <summary>
/// True if the indicator has processed at least 2 bars.
/// </summary>
public bool IsHot => _s.Index >= 2;
/// <summary>
/// Warmup period required before the indicator is considered hot.
/// </summary>
#pragma warning disable S2325 // Instance property required by indicator interface convention
public int WarmupPeriod => 2;
#pragma warning restore S2325
/// <summary>
/// Creates a new PVI indicator.
/// </summary>
/// <param name="startValue">Initial PVI value (default: 100)</param>
/// <exception cref="ArgumentException">Thrown when startValue is not positive.</exception>
public Pvi(double startValue = 100.0)
{
if (startValue <= 0)
{
throw new ArgumentException("Start value must be positive", nameof(startValue));
}
_startValue = startValue;
_s = new State(PviValue: startValue, PrevClose: 0, PrevVolume: 0, LastValidClose: 0, LastValidVolume: 0, Index: 0);
_ps = _s;
Name = $"Pvi({startValue})";
}
/// <summary>
/// Resets the indicator state.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Reset()
{
_s = new State(PviValue: _startValue, PrevClose: 0, PrevVolume: 0, LastValidClose: 0, LastValidVolume: 0, Index: 0);
_ps = _s;
Last = default;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TBar input, bool isNew = true)
{
if (isNew)
{
_ps = _s;
}
else
{
_s = _ps;
}
var s = _s;
// Handle NaN/Infinity in close and volume
double close = double.IsFinite(input.Close) ? input.Close : s.LastValidClose;
double volume = double.IsFinite(input.Volume) ? input.Volume : s.LastValidVolume;
if (double.IsFinite(input.Close) && input.Close > 0)
{
s.LastValidClose = input.Close;
}
if (double.IsFinite(input.Volume) && input.Volume > 0)
{
s.LastValidVolume = input.Volume;
}
// Calculate PVI - only update when volume increases
// Matches PineScript: if not (na(src) or na(vol) or na(src[1]) or na(vol[1]) or src[1] == 0.0 or vol[1] <= 0.0) and vol > vol[1]
if (s.Index > 0 && s.PrevClose > 0 && s.PrevVolume > 0 && volume > s.PrevVolume)
{
s.PviValue *= close / s.PrevClose;
}
// If volume <= previous volume, PVI stays the same
// Store for next iteration
s.PrevClose = close;
s.PrevVolume = volume;
if (isNew)
{
s.Index++;
}
_s = s;
Last = new TValue(input.Time, s.PviValue);
Pub?.Invoke(this, new TValueEventArgs { Value = Last, IsNew = isNew });
return Last;
}
/// <summary>
/// Updates PVI with a TValue input.
/// </summary>
/// <remarks>
/// PVI requires volume data to determine when to update. Using TValue without
/// volume data will keep PVI unchanged. For proper PVI calculation, use Update(TBar).
/// </remarks>
#pragma warning disable S2325 // Method signature must match ITValuePublisher contract
public TValue Update(TValue input, bool isNew = true)
#pragma warning restore S2325
{
// PVI requires volume; without it, we can't determine direction
// Return current value unchanged
if (isNew)
{
_ps = _s;
}
else
{
_s = _ps;
}
Last = new TValue(input.Time, _s.PviValue);
Pub?.Invoke(this, new TValueEventArgs { Value = Last, IsNew = isNew });
return Last;
}
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);
}
public static TSeries Calculate(TBarSeries source, double startValue = 100.0)
{
if (source.Count == 0)
{
return [];
}
var t = source.Close.Times.ToArray();
var v = new double[source.Count];
Calculate(source.Close.Values, source.Volume.Values, v, startValue);
return new TSeries(t, v);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Calculate(ReadOnlySpan<double> close, ReadOnlySpan<double> volume, Span<double> output, double startValue = 100.0)
{
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));
}
if (startValue <= 0)
{
throw new ArgumentException("Start value must be positive", nameof(startValue));
}
int len = close.Length;
if (len == 0)
{
return;
}
// Track last valid values for NaN/Infinity substitution (mirrors Update behavior)
double lastValidClose = 0;
double lastValidVolume = 0;
// First value is just the start value
output[0] = startValue;
// Handle first bar's close/volume for last-valid tracking
if (double.IsFinite(close[0]) && close[0] > 0)
{
lastValidClose = close[0];
}
if (double.IsFinite(volume[0]) && volume[0] > 0)
{
lastValidVolume = volume[0];
}
// Sanitized previous values for PVI calculation
double prevClose = double.IsFinite(close[0]) ? close[0] : lastValidClose;
double prevVolume = double.IsFinite(volume[0]) ? volume[0] : lastValidVolume;
double pvi = startValue;
for (int i = 1; i < len; i++)
{
// Sanitize current close/volume (substitute last-valid if not finite)
double currentClose = double.IsFinite(close[i]) ? close[i] : lastValidClose;
double currentVolume = double.IsFinite(volume[i]) ? volume[i] : lastValidVolume;
// Update last-valid tracking when values are finite and > 0
if (double.IsFinite(close[i]) && close[i] > 0)
{
lastValidClose = close[i];
}
if (double.IsFinite(volume[i]) && volume[i] > 0)
{
lastValidVolume = volume[i];
}
// Only update when volume increases (using sanitized values)
// Matches PineScript: if not (na(src) or na(vol) or na(src[1]) or na(vol[1]) or src[1] == 0.0 or vol[1] <= 0.0) and vol > vol[1]
if (prevClose > 0 && prevVolume > 0 && currentVolume > prevVolume)
{
pvi *= currentClose / prevClose;
}
// Otherwise PVI stays the same
output[i] = pvi;
// Store sanitized values for next iteration
prevClose = currentClose;
prevVolume = currentVolume;
}
}
}