using System.Runtime.CompilerServices; using System.Runtime.InteropServices; namespace QuanTAlib; /// /// KVO: Klinger Volume Oscillator /// /// /// Volume-based oscillator comparing volume flow with price movements for money flow trends. /// Positive values indicate accumulation; negative indicates distribution. /// /// Calculation: HLC3 = (H+L+C)/3, Trend = ±1 based on HLC3 direction, /// DM = Trend × Volume × CM, KVO = EMA(DM, fast) - EMA(DM, slow). /// /// Detailed documentation /// Reference Pine Script implementation [SkipLocalsInit] public sealed class Kvo : ITValuePublisher { [StructLayout(LayoutKind.Auto)] private record struct State { public double PrevHlc3; public double Trend; public double EmaFast; public double EmaSlow; public double EmaSignal; public double EFast; public double ESlow; public double ESignal; public double LastValidValue; public bool HasPrevHlc3; } private State _s; private State _ps; private readonly double _alphaFast; private readonly double _alphaSlow; private readonly double _alphaSignal; private readonly double _decayFast; private readonly double _decaySlow; private readonly double _decaySignal; 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 bool IsHot { get; private set; } public event TValuePublishedHandler? Pub; /// /// Initializes a new instance of the Kvo class. /// /// The fast EMA period (default: 34) /// The slow EMA period (default: 55) /// The signal line EMA period (default: 13) /// Thrown when periods are invalid public Kvo(int fastPeriod = 34, int slowPeriod = 55, int signalPeriod = 13) { 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); _decayFast = 1.0 - _alphaFast; _decaySlow = 1.0 - _alphaSlow; _decaySignal = 1.0 - _alphaSignal; WarmupPeriod = slowPeriod; Name = $"Kvo({fastPeriod},{slowPeriod},{signalPeriod})"; _s = new State { Trend = 1.0, EFast = 1.0, ESlow = 1.0, ESignal = 1.0, LastValidValue = 0.0 }; _ps = _s; } /// /// Updates the indicator with a new bar. /// /// The bar data containing High, Low, Close, and Volume /// Whether this is a new bar or an update to the current bar /// The calculated KVO value [MethodImpl(MethodImplOptions.AggressiveInlining)] public TValue Update(TBar bar, bool isNew = true) { if (isNew) { _ps = _s; } else { _s = _ps; } var s = _s; double high = bar.High; double low = bar.Low; double close = bar.Close; double volume = Math.Max(bar.Volume, 0.0); // Calculate HLC3 (typical price) double hlc3 = (high + low + close) / 3.0; // Determine trend direction if (s.HasPrevHlc3) { if (hlc3 > s.PrevHlc3) { s.Trend = 1.0; } else if (hlc3 < s.PrevHlc3) { s.Trend = -1.0; } // else trend unchanged } // Calculate price range and cumulation measure (CM) double range = high - low; double cm = 0.0; if (range > 0) { cm = Math.Abs(2.0 * ((range - (close - low)) / range) - 1.0); } // Calculate direction multiplier (DM) double dm = s.Trend * volume * cm; // Handle NaN/Infinity if (!double.IsFinite(dm)) { dm = s.LastValidValue; } else { s.LastValidValue = dm; } // Update EMAs with FMA s.EmaFast = Math.FusedMultiplyAdd(s.EmaFast, _decayFast, _alphaFast * dm); s.EmaSlow = Math.FusedMultiplyAdd(s.EmaSlow, _decaySlow, _alphaSlow * dm); // Calculate compensated EMA values double fastValue, slowValue; bool warmupComplete = true; if (s.EFast > COMPENSATOR_THRESHOLD) { s.EFast *= _decayFast; fastValue = s.EmaFast / (1.0 - s.EFast); warmupComplete = false; } else { fastValue = s.EmaFast; } if (s.ESlow > COMPENSATOR_THRESHOLD) { s.ESlow *= _decaySlow; slowValue = s.EmaSlow / (1.0 - s.ESlow); warmupComplete = false; } else { slowValue = s.EmaSlow; } // Calculate KVO line double kvoLine = fastValue - slowValue; // Update signal EMA s.EmaSignal = Math.FusedMultiplyAdd(s.EmaSignal, _decaySignal, _alphaSignal * kvoLine); // Calculate compensated signal value double signalValue; if (s.ESignal > COMPENSATOR_THRESHOLD) { s.ESignal *= _decaySignal; signalValue = s.EmaSignal / (1.0 - s.ESignal); } else { signalValue = s.EmaSignal; } // Update previous HLC3 s.PrevHlc3 = hlc3; s.HasPrevHlc3 = true; _s = s; IsHot = warmupComplete; Last = new TValue(bar.Time, kvoLine); Signal = new TValue(bar.Time, signalValue); Pub?.Invoke(this, new TValueEventArgs { Value = Last, IsNew = isNew }); return Last; } /// /// TValue input is not supported for KVO - requires TBar (OHLCV) data. /// #pragma warning disable S2325 // Method signature must match ITValuePublisher contract public TValue Update(TValue value, bool isNew = true) #pragma warning restore S2325 { throw new NotSupportedException("KVO requires TBar (OHLCV) data. Use Update(TBar) instead."); } /// /// Updates KVO 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 KVO with a bar series and returns both KVO and Signal. /// public (TSeries Kvo, TSeries Signal) UpdateWithSignal(TBarSeries source) { var tKvo = new List(source.Count); var vKvo = new List(source.Count); var tSignal = new List(source.Count); var vSignal = new List(source.Count); Reset(); for (int i = 0; i < source.Count; i++) { var val = Update(source[i], isNew: true); tKvo.Add(val.Time); vKvo.Add(val.Value); tSignal.Add(Signal.Time); vSignal.Add(Signal.Value); } return (new TSeries(tKvo, vKvo), new TSeries(tSignal, vSignal)); } /// /// Resets the indicator to its initial state. /// public void Reset() { _s = new State { Trend = 1.0, EFast = 1.0, ESlow = 1.0, ESignal = 1.0, LastValidValue = 0.0 }; _ps = _s; IsHot = false; Last = default; Signal = 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 KVO 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 KVO values public static TSeries Batch(TBarSeries bars, int fastPeriod = 34, int slowPeriod = 55, int signalPeriod = 13) { if (bars.Count == 0) { return []; } var t = bars.Open.Times.ToArray(); var v = new double[bars.Count]; var signal = new double[bars.Count]; Batch(bars.High.Values, bars.Low.Values, bars.Close.Values, bars.Volume.Values, v, signal, fastPeriod, slowPeriod, signalPeriod); return new TSeries(t, v); } /// /// Calculates KVO values using span-based processing. /// /// Source high prices /// Source low prices /// Source close prices /// Source volumes /// Output span for KVO values /// Output span for signal line 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 high, ReadOnlySpan low, ReadOnlySpan close, ReadOnlySpan volume, Span output, Span signal, int fastPeriod = 34, int slowPeriod = 55, int signalPeriod = 13) { if (high.Length != low.Length) { throw new ArgumentException("High and low spans must have the same length", nameof(low)); } if (high.Length != close.Length) { throw new ArgumentException("High and close spans must have the same length", nameof(close)); } if (high.Length != volume.Length) { throw new ArgumentException("High and volume spans must have the same length", nameof(volume)); } if (high.Length != output.Length) { throw new ArgumentException("Output span must have the same length as input", nameof(output)); } if (high.Length != signal.Length) { throw new ArgumentException("Signal span must have the same length as input", nameof(signal)); } 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)); } int length = high.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 decayFast = 1.0 - alphaFast; double decaySlow = 1.0 - alphaSlow; double decaySignal = 1.0 - alphaSignal; // State variables double prevHlc3 = (high[0] + low[0] + close[0]) / 3.0; double trend = 1.0; double emaFast = 0.0; double emaSlow = 0.0; double emaSignal = 0.0; double eFast = 1.0; double eSlow = 1.0; double eSignal = 1.0; for (int i = 0; i < length; i++) { double h = high[i]; double l = low[i]; double c = close[i]; double vol = Math.Max(volume[i], 0.0); // Calculate HLC3 double hlc3 = (h + l + c) / 3.0; // Determine trend direction if (i > 0) { if (hlc3 > prevHlc3) { trend = 1.0; } else if (hlc3 < prevHlc3) { trend = -1.0; } } // Calculate CM double range = h - l; double cm = range > 0 ? Math.Abs(2.0 * ((range - (c - l)) / range) - 1.0) : 0.0; // Calculate DM double dm = trend * vol * cm; if (!double.IsFinite(dm)) { dm = i > 0 ? output[i - 1] : 0.0; } // Update EMAs emaFast = Math.FusedMultiplyAdd(emaFast, decayFast, alphaFast * dm); emaSlow = Math.FusedMultiplyAdd(emaSlow, decaySlow, alphaSlow * dm); // Calculate compensated values double fastValue, slowValue; if (eFast > COMPENSATOR_THRESHOLD) { eFast *= decayFast; fastValue = emaFast / (1.0 - eFast); } else { fastValue = emaFast; } if (eSlow > COMPENSATOR_THRESHOLD) { eSlow *= decaySlow; slowValue = emaSlow / (1.0 - eSlow); } else { slowValue = emaSlow; } // Calculate KVO double kvoLine = fastValue - slowValue; output[i] = kvoLine; // Update signal EMA emaSignal = Math.FusedMultiplyAdd(emaSignal, decaySignal, alphaSignal * kvoLine); if (eSignal > COMPENSATOR_THRESHOLD) { eSignal *= decaySignal; signal[i] = emaSignal / (1.0 - eSignal); } else { signal[i] = emaSignal; } prevHlc3 = hlc3; } } public static (TSeries Results, Kvo Indicator) Calculate(TBarSeries bars, int fastPeriod = 34, int slowPeriod = 55, int signalPeriod = 13) { var indicator = new Kvo(fastPeriod, slowPeriod, signalPeriod); TSeries results = indicator.Update(bars); return (results, indicator); } }