using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
///
/// Computes the Percentage Volume Oscillator (PVO) that measures the difference between two
/// volume EMAs as a percentage of the slower EMA, similar to MACD applied to volume.
///
///
/// PVO Formula:
/// PVO = ((EMA_fast - EMA_slow) / EMA_slow) × 100,
/// Signal = EMA(PVO, signalPeriod),
/// Histogram = PVO - Signal.
///
/// Positive values indicate volume above average (bullish); negative indicates below average (bearish).
/// This implementation is optimized for streaming updates with O(1) per bar using EMA compensators
/// for proper early-stage bias correction.
/// Non-finite inputs (NaN/±Inf) are sanitized by substituting the last finite value observed.
///
/// For the authoritative algorithm reference, full rationale, and behavioral contracts, see the
/// companion files in the same directory.
///
/// Detailed documentation
/// Reference Pine Script implementation
[SkipLocalsInit]
public sealed class Pvo : ITValuePublisher
{
[StructLayout(LayoutKind.Auto)]
private record struct State
{
public double EmaFast;
public double EmaSlow;
public double EmaSignal;
public double EFast;
public double ESlow;
public double ESignal;
public double ESlowest;
public bool Warmup;
public double LastValidVolume;
}
private State _s;
private State _ps;
private readonly double _alphaFast;
private readonly double _alphaSlow;
private readonly double _alphaSignal;
private readonly double _betaFast;
private readonly double _betaSlow;
private readonly double _betaSignal;
private readonly double _betaSlowest;
private const double COMPENSATOR_THRESHOLD = 1e-10;
public string Name { get; }
public int WarmupPeriod { get; }
public TValue Last { get; private set; }
public TValue Signal { get; private set; }
public TValue Histogram { get; private set; }
public bool IsHot => !_s.Warmup;
public event TValuePublishedHandler? Pub;
///
/// Initializes a new instance of the Pvo class.
///
/// The fast EMA period (default: 12)
/// The slow EMA period (default: 26)
/// The signal line EMA period (default: 9)
/// Thrown when periods are invalid
public Pvo(int fastPeriod = 12, int slowPeriod = 26, int signalPeriod = 9)
{
if (fastPeriod < 1)
{
throw new ArgumentException("Fast period must be >= 1", nameof(fastPeriod));
}
if (slowPeriod < 1)
{
throw new ArgumentException("Slow period must be >= 1", nameof(slowPeriod));
}
if (signalPeriod < 1)
{
throw new ArgumentException("Signal period must be >= 1", nameof(signalPeriod));
}
if (fastPeriod >= slowPeriod)
{
throw new ArgumentException("Fast period must be less than slow period", nameof(fastPeriod));
}
_alphaFast = 2.0 / (fastPeriod + 1);
_alphaSlow = 2.0 / (slowPeriod + 1);
_alphaSignal = 2.0 / (signalPeriod + 1);
_betaFast = 1.0 - _alphaFast;
_betaSlow = 1.0 - _alphaSlow;
_betaSignal = 1.0 - _alphaSignal;
_betaSlowest = Math.Max(Math.Max(_betaFast, _betaSlow), _betaSignal);
WarmupPeriod = slowPeriod;
Name = $"Pvo({fastPeriod},{slowPeriod},{signalPeriod})";
_s = new State
{
EFast = 1.0,
ESlow = 1.0,
ESignal = 1.0,
ESlowest = 1.0,
Warmup = true,
LastValidVolume = 0.0
};
_ps = _s;
}
///
/// Updates the indicator with a new bar.
///
/// The bar data containing Volume
/// Whether this is a new bar or an update to the current bar
/// The calculated PVO value
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TBar bar, bool isNew = true)
{
return Update(new TValue(bar.Time, bar.Volume), isNew);
}
///
/// Updates the indicator with a TValue (volume).
///
/// The volume value
/// Whether this is a new bar or an update to the current bar
/// The calculated PVO value
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TValue value, bool isNew = true)
{
if (isNew)
{
_ps = _s;
}
else
{
_s = _ps;
}
var s = _s;
// Handle NaN/Infinity in volume
double volume = double.IsFinite(value.Value) ? Math.Max(value.Value, 0.0) : s.LastValidVolume;
if (double.IsFinite(value.Value))
{
s.LastValidVolume = Math.Max(value.Value, 0.0);
}
// Update EMAs using standard EMA formula: ema = alpha * (value - ema) + ema
s.EmaFast = Math.FusedMultiplyAdd(_alphaFast, volume - s.EmaFast, s.EmaFast);
s.EmaSlow = Math.FusedMultiplyAdd(_alphaSlow, volume - s.EmaSlow, s.EmaSlow);
// Calculate compensated EMA values during warmup
double fastComp, slowComp;
if (s.Warmup)
{
s.EFast *= _betaFast;
s.ESlow *= _betaSlow;
s.ESignal *= _betaSignal;
s.ESlowest *= _betaSlowest;
s.Warmup = s.ESlowest > COMPENSATOR_THRESHOLD;
fastComp = s.EmaFast / (1.0 - s.EFast);
slowComp = s.EmaSlow / (1.0 - s.ESlow);
}
else
{
fastComp = s.EmaFast;
slowComp = s.EmaSlow;
}
// Calculate PVO: ((fastEMA - slowEMA) / slowEMA) * 100
double pvoValue = Math.Abs(slowComp) > 0 ? ((fastComp - slowComp) / slowComp) * 100.0 : 0.0;
// Update signal EMA
s.EmaSignal = Math.FusedMultiplyAdd(_alphaSignal, pvoValue - s.EmaSignal, s.EmaSignal);
// Calculate compensated signal value
double signalValue = s.Warmup ? s.EmaSignal / (1.0 - s.ESignal) : s.EmaSignal;
// Calculate histogram
double histogramValue = pvoValue - signalValue;
_s = s;
Last = new TValue(value.Time, pvoValue);
Signal = new TValue(value.Time, signalValue);
Histogram = new TValue(value.Time, histogramValue);
Pub?.Invoke(this, new TValueEventArgs { Value = Last, IsNew = isNew });
return Last;
}
///
/// Updates PVO with a bar series.
///
public TSeries Update(TBarSeries source)
{
var t = new List(source.Count);
var v = new List(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);
}
///
/// Updates PVO with a bar series and returns PVO, Signal, and Histogram.
///
public (TSeries Pvo, TSeries Signal, TSeries Histogram) UpdateWithSignal(TBarSeries source)
{
var tPvo = new List(source.Count);
var vPvo = new List(source.Count);
var tSignal = new List(source.Count);
var vSignal = new List(source.Count);
var tHistogram = new List(source.Count);
var vHistogram = new List(source.Count);
Reset();
for (int i = 0; i < source.Count; i++)
{
var val = Update(source[i], isNew: true);
tPvo.Add(val.Time);
vPvo.Add(val.Value);
tSignal.Add(Signal.Time);
vSignal.Add(Signal.Value);
tHistogram.Add(Histogram.Time);
vHistogram.Add(Histogram.Value);
}
return (new TSeries(tPvo, vPvo), new TSeries(tSignal, vSignal), new TSeries(tHistogram, vHistogram));
}
///
/// Resets the indicator to its initial state.
///
public void Reset()
{
_s = new State
{
EFast = 1.0,
ESlow = 1.0,
ESignal = 1.0,
ESlowest = 1.0,
Warmup = true,
LastValidVolume = 0.0
};
_ps = _s;
Last = default;
Signal = default;
Histogram = default;
}
///
/// Initializes the indicator state using the provided bar series history.
///
/// Historical bar data.
public void Prime(TBarSeries source)
{
Reset();
if (source.Count == 0)
{
return;
}
for (int i = 0; i < source.Count; i++)
{
Update(source[i], isNew: true);
}
}
///
/// Calculates PVO for a series of bars.
///
/// The input bar series
/// The fast EMA period
/// The slow EMA period
/// The signal line EMA period
/// A TSeries containing the PVO values
public static TSeries Batch(TBarSeries bars, int fastPeriod = 12, int slowPeriod = 26, int signalPeriod = 9)
{
if (bars.Count == 0)
{
return [];
}
var t = bars.Open.Times.ToArray();
var v = new double[bars.Count];
var signal = new double[bars.Count];
var histogram = new double[bars.Count];
Batch(bars.Volume.Values, v, signal, histogram, fastPeriod, slowPeriod, signalPeriod);
return new TSeries(t, v);
}
///
/// Calculates PVO values using span-based processing.
///
/// Source volumes
/// Output span for PVO values
/// Output span for signal line values
/// Output span for histogram values
/// The fast EMA period
/// The slow EMA period
/// The signal line EMA period
/// Thrown when spans have different lengths or parameters are invalid
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan volume, Span output, Span signal,
Span histogram, int fastPeriod = 12, int slowPeriod = 26, int signalPeriod = 9)
{
if (volume.Length != output.Length)
{
throw new ArgumentException("Output span must have the same length as input", nameof(output));
}
if (volume.Length != signal.Length)
{
throw new ArgumentException("Signal span must have the same length as input", nameof(signal));
}
if (volume.Length != histogram.Length)
{
throw new ArgumentException("Histogram span must have the same length as input", nameof(histogram));
}
if (fastPeriod < 1)
{
throw new ArgumentException("Fast period must be >= 1", nameof(fastPeriod));
}
if (slowPeriod < 1)
{
throw new ArgumentException("Slow period must be >= 1", nameof(slowPeriod));
}
if (signalPeriod < 1)
{
throw new ArgumentException("Signal period must be >= 1", nameof(signalPeriod));
}
if (fastPeriod >= slowPeriod)
{
throw new ArgumentException("Fast period must be less than slow period", nameof(fastPeriod));
}
int length = volume.Length;
if (length == 0)
{
return;
}
// EMA parameters
double alphaFast = 2.0 / (fastPeriod + 1);
double alphaSlow = 2.0 / (slowPeriod + 1);
double alphaSignal = 2.0 / (signalPeriod + 1);
double betaFast = 1.0 - alphaFast;
double betaSlow = 1.0 - alphaSlow;
double betaSignal = 1.0 - alphaSignal;
double betaSlowest = Math.Max(Math.Max(betaFast, betaSlow), betaSignal);
// State variables
double emaFast = 0.0;
double emaSlow = 0.0;
double emaSignal = 0.0;
double eFast = 1.0;
double eSlow = 1.0;
double eSignal = 1.0;
double eSlowest = 1.0;
bool warmup = true;
for (int i = 0; i < length; i++)
{
double vol = Math.Max(volume[i], 0.0);
if (!double.IsFinite(vol))
{
vol = i > 0 ? Math.Max(volume[i - 1], 0.0) : 0.0;
}
// Update EMAs
emaFast = Math.FusedMultiplyAdd(alphaFast, vol - emaFast, emaFast);
emaSlow = Math.FusedMultiplyAdd(alphaSlow, vol - emaSlow, emaSlow);
// Calculate compensated values
double fastComp, slowComp;
if (warmup)
{
eFast *= betaFast;
eSlow *= betaSlow;
eSignal *= betaSignal;
eSlowest *= betaSlowest;
warmup = eSlowest > COMPENSATOR_THRESHOLD;
fastComp = emaFast / (1.0 - eFast);
slowComp = emaSlow / (1.0 - eSlow);
}
else
{
fastComp = emaFast;
slowComp = emaSlow;
}
// Calculate PVO
double pvoValue = Math.Abs(slowComp) > 0 ? ((fastComp - slowComp) / slowComp) * 100.0 : 0.0;
output[i] = pvoValue;
// Update signal EMA
emaSignal = Math.FusedMultiplyAdd(alphaSignal, pvoValue - emaSignal, emaSignal);
// Calculate compensated signal
double signalValue = warmup ? emaSignal / (1.0 - eSignal) : emaSignal;
signal[i] = signalValue;
// Calculate histogram
histogram[i] = pvoValue - signalValue;
}
}
public static (TSeries Results, Pvo Indicator) Calculate(TBarSeries bars, int fastPeriod = 12, int slowPeriod = 26, int signalPeriod = 9)
{
var indicator = new Pvo(fastPeriod, slowPeriod, signalPeriod);
TSeries results = indicator.Update(bars);
return (results, indicator);
}
}