mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-24 05:28:05 +00:00
Add Intraday Intensity Index (III) implementation and tests
- Implemented the III indicator in Iii.Quantower.cs, measuring buying/selling pressure based on close price within the day's range, weighted by volume. - Added unit tests for III functionality in Iii.Tests.cs, covering various scenarios including default parameters, updates, and cumulative mode. - Created validation tests in Iii.Validation.Tests.cs to ensure consistency between streaming, batch, and span calculations. - Developed comprehensive documentation for III in Iii.md, detailing its historical context, mathematical foundation, and common pitfalls.
This commit is contained in:
@@ -0,0 +1,432 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// AOBV: Archer On-Balance Volume
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Archer On-Balance Volume applies dual EMA smoothing to On-Balance Volume (OBV)
|
||||
/// to create fast and slow signal lines. The indicator helps identify volume-based
|
||||
/// momentum and potential trend changes.
|
||||
///
|
||||
/// Calculation:
|
||||
/// 1. OBV = cumulative sum of volume when close > prev_close, minus volume when close < prev_close
|
||||
/// 2. AOBV Fast = EMA(OBV, 4) with warmup compensation
|
||||
/// 3. AOBV Slow = EMA(OBV, 14) with warmup compensation
|
||||
///
|
||||
/// The crossover of fast and slow lines can signal trend changes:
|
||||
/// - Fast crossing above slow indicates bullish momentum
|
||||
/// - Fast crossing below slow indicates bearish momentum
|
||||
///
|
||||
/// Sources:
|
||||
/// https://github.com/mihakralj/pinescript/blob/main/indicators/volume/aobv.md
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Aobv : ITValuePublisher
|
||||
{
|
||||
private const int FastPeriod = 4;
|
||||
private const int SlowPeriod = 14;
|
||||
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private record struct State
|
||||
{
|
||||
public double Obv;
|
||||
public double EmaFast;
|
||||
public double EmaSlow;
|
||||
public double EFast;
|
||||
public double ESlow;
|
||||
public double PrevClose;
|
||||
public double LastValidClose; // NaN sentinel - no valid value yet
|
||||
public double LastValidVolume; // NaN sentinel - no valid value yet
|
||||
public bool WarmupFast;
|
||||
public bool WarmupSlow;
|
||||
public int Index;
|
||||
}
|
||||
|
||||
private State _s;
|
||||
private State _ps;
|
||||
|
||||
private readonly double _alphaFast;
|
||||
private readonly double _betaFast;
|
||||
private readonly double _alphaSlow;
|
||||
private readonly double _betaSlow;
|
||||
|
||||
#pragma warning disable S2325 // Interface contract cannot be static
|
||||
public string Name => "AOBV(4,14)";
|
||||
#pragma warning restore S2325
|
||||
public event TValuePublishedHandler? Pub;
|
||||
public TValue Last { get; private set; }
|
||||
public TValue LastFast { get; private set; }
|
||||
public TValue LastSlow { get; private set; }
|
||||
public bool IsHot => _s.Index >= SlowPeriod;
|
||||
#pragma warning disable S2325 // Interface contract cannot be static
|
||||
public int WarmupPeriod => SlowPeriod;
|
||||
#pragma warning restore S2325
|
||||
|
||||
public Aobv()
|
||||
{
|
||||
_alphaFast = 2.0 / (FastPeriod + 1);
|
||||
_betaFast = 1.0 - _alphaFast;
|
||||
_alphaSlow = 2.0 / (SlowPeriod + 1);
|
||||
_betaSlow = 1.0 - _alphaSlow;
|
||||
|
||||
_s = new State
|
||||
{
|
||||
EFast = 1.0,
|
||||
ESlow = 1.0,
|
||||
WarmupFast = true,
|
||||
WarmupSlow = true,
|
||||
LastValidClose = double.NaN, // NaN sentinel until first valid value
|
||||
LastValidVolume = double.NaN // NaN sentinel until first valid value
|
||||
};
|
||||
_ps = _s;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public void Reset()
|
||||
{
|
||||
_s = new State
|
||||
{
|
||||
EFast = 1.0,
|
||||
ESlow = 1.0,
|
||||
WarmupFast = true,
|
||||
WarmupSlow = true,
|
||||
LastValidClose = double.NaN, // NaN sentinel until first valid value
|
||||
LastValidVolume = double.NaN // NaN sentinel until first valid value
|
||||
};
|
||||
_ps = _s;
|
||||
Last = default;
|
||||
LastFast = default;
|
||||
LastSlow = 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 for close - use input if finite, else last valid, else skip this bar's OBV contribution
|
||||
double close;
|
||||
if (double.IsFinite(input.Close))
|
||||
{
|
||||
close = input.Close;
|
||||
s.LastValidClose = input.Close;
|
||||
}
|
||||
else if (double.IsFinite(s.LastValidClose))
|
||||
{
|
||||
close = s.LastValidClose;
|
||||
}
|
||||
else
|
||||
{
|
||||
// No valid close seen yet - use 0 as neutral (won't affect OBV comparison meaningfully on first bar)
|
||||
close = 0;
|
||||
}
|
||||
|
||||
// Handle NaN/Infinity for volume - use input if finite, else last valid, else 0 (neutral)
|
||||
double volume;
|
||||
if (double.IsFinite(input.Volume))
|
||||
{
|
||||
volume = input.Volume;
|
||||
s.LastValidVolume = input.Volume;
|
||||
}
|
||||
else if (double.IsFinite(s.LastValidVolume))
|
||||
{
|
||||
volume = s.LastValidVolume;
|
||||
}
|
||||
else
|
||||
{
|
||||
// No valid volume seen yet - use 0 as neutral (won't change OBV)
|
||||
volume = 0;
|
||||
}
|
||||
|
||||
// Calculate OBV
|
||||
if (s.Index == 0)
|
||||
{
|
||||
s.Obv = 0; // First bar, no comparison - matches span Calculate
|
||||
}
|
||||
else
|
||||
{
|
||||
double prevClose = s.PrevClose;
|
||||
if (close > prevClose)
|
||||
{
|
||||
s.Obv += volume;
|
||||
}
|
||||
else if (close < prevClose)
|
||||
{
|
||||
s.Obv -= volume;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate EMA Fast with warmup compensation
|
||||
if (s.Index == 0)
|
||||
{
|
||||
s.EmaFast = 0;
|
||||
}
|
||||
|
||||
s.EmaFast = Math.FusedMultiplyAdd(_alphaFast, s.Obv - s.EmaFast, s.EmaFast);
|
||||
|
||||
double resultFast;
|
||||
if (s.WarmupFast)
|
||||
{
|
||||
s.EFast *= _betaFast;
|
||||
double c = 1.0 / (1.0 - s.EFast);
|
||||
resultFast = c * s.EmaFast;
|
||||
if (s.EFast <= 1e-10)
|
||||
{
|
||||
s.WarmupFast = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
resultFast = s.EmaFast;
|
||||
}
|
||||
|
||||
// Calculate EMA Slow with warmup compensation
|
||||
if (s.Index == 0)
|
||||
{
|
||||
s.EmaSlow = 0;
|
||||
}
|
||||
|
||||
s.EmaSlow = Math.FusedMultiplyAdd(_alphaSlow, s.Obv - s.EmaSlow, s.EmaSlow);
|
||||
|
||||
double resultSlow;
|
||||
if (s.WarmupSlow)
|
||||
{
|
||||
s.ESlow *= _betaSlow;
|
||||
double c = 1.0 / (1.0 - s.ESlow);
|
||||
resultSlow = c * s.EmaSlow;
|
||||
if (s.ESlow <= 1e-10)
|
||||
{
|
||||
s.WarmupSlow = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
resultSlow = s.EmaSlow;
|
||||
}
|
||||
|
||||
// Store previous close for next iteration
|
||||
s.PrevClose = close;
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
s.Index++;
|
||||
}
|
||||
|
||||
_s = s;
|
||||
|
||||
LastFast = new TValue(input.Time, resultFast);
|
||||
LastSlow = new TValue(input.Time, resultSlow);
|
||||
Last = LastFast; // Primary output is fast line
|
||||
|
||||
Pub?.Invoke(this, new TValueEventArgs { Value = Last, IsNew = isNew });
|
||||
return Last;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates AOBV with a TValue input.
|
||||
/// </summary>
|
||||
/// <exception cref="NotSupportedException">
|
||||
/// AOBV requires OHLCV bar data to calculate OBV from close and volume.
|
||||
/// Use Update(TBar) instead.
|
||||
/// </exception>
|
||||
#pragma warning disable S2325 // Method signature must match ITValuePublisher contract
|
||||
public TValue Update(TValue input, bool isNew = true)
|
||||
#pragma warning restore S2325
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"AOBV requires OHLCV bar data to calculate OBV from close and volume. " +
|
||||
"Use Update(TBar) instead.");
|
||||
}
|
||||
|
||||
public (TSeries Fast, TSeries Slow) Update(TBarSeries source)
|
||||
{
|
||||
var tFast = new List<long>(source.Count);
|
||||
var vFast = new List<double>(source.Count);
|
||||
var tSlow = new List<long>(source.Count);
|
||||
var vSlow = new List<double>(source.Count);
|
||||
|
||||
Reset();
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Update(source[i], isNew: true);
|
||||
tFast.Add(LastFast.Time);
|
||||
vFast.Add(LastFast.Value);
|
||||
tSlow.Add(LastSlow.Time);
|
||||
vSlow.Add(LastSlow.Value);
|
||||
}
|
||||
|
||||
return (new TSeries(tFast, vFast), new TSeries(tSlow, vSlow));
|
||||
}
|
||||
|
||||
public static (TSeries Fast, TSeries Slow) Calculate(TBarSeries source)
|
||||
{
|
||||
if (source.Count == 0)
|
||||
{
|
||||
return ([], []);
|
||||
}
|
||||
|
||||
var t = source.Open.Times.ToArray();
|
||||
var vFast = new double[source.Count];
|
||||
var vSlow = new double[source.Count];
|
||||
|
||||
Calculate(source.Close.Values, source.Volume.Values, vFast, vSlow);
|
||||
|
||||
return (new TSeries(t, vFast), new TSeries(t, vSlow));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates AOBV (Archer On-Balance Volume) from close and volume spans.
|
||||
/// </summary>
|
||||
/// <param name="close">Input close prices. NaN/Infinity values are replaced with last valid value.</param>
|
||||
/// <param name="volume">Input volume values. NaN/Infinity values are replaced with last valid value.</param>
|
||||
/// <param name="outputFast">Output span for fast EMA line.</param>
|
||||
/// <param name="outputSlow">Output span for slow EMA line.</param>
|
||||
/// <remarks>
|
||||
/// Input sanitization: NaN/Infinity values in close or volume are replaced with the last valid
|
||||
/// value seen. If no valid value has been seen yet, 0 is used as a neutral fallback.
|
||||
/// </remarks>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void Calculate(ReadOnlySpan<double> close, ReadOnlySpan<double> volume,
|
||||
Span<double> outputFast, Span<double> outputSlow)
|
||||
{
|
||||
if (close.Length != volume.Length)
|
||||
{
|
||||
throw new ArgumentException("Close and Volume spans must be of the same length", nameof(volume));
|
||||
}
|
||||
|
||||
if (close.Length != outputFast.Length)
|
||||
{
|
||||
throw new ArgumentException("Output Fast span must be of the same length as input", nameof(outputFast));
|
||||
}
|
||||
|
||||
if (close.Length != outputSlow.Length)
|
||||
{
|
||||
throw new ArgumentException("Output Slow span must be of the same length as input", nameof(outputSlow));
|
||||
}
|
||||
|
||||
int len = close.Length;
|
||||
if (len == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double alphaFast = 2.0 / (FastPeriod + 1);
|
||||
double betaFast = 1.0 - alphaFast;
|
||||
double alphaSlow = 2.0 / (SlowPeriod + 1);
|
||||
double betaSlow = 1.0 - alphaSlow;
|
||||
|
||||
double obv = 0;
|
||||
double emaFast = 0;
|
||||
double emaSlow = 0;
|
||||
double eFast = 1.0;
|
||||
double eSlow = 1.0;
|
||||
bool warmupFast = true;
|
||||
bool warmupSlow = true;
|
||||
|
||||
// NaN sentinel for last valid values
|
||||
double lastValidClose = double.NaN;
|
||||
double lastValidVolume = double.NaN;
|
||||
double prevClose = 0;
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
// Handle NaN/Infinity for close - use input if finite, else last valid, else 0 (neutral)
|
||||
double c;
|
||||
if (double.IsFinite(close[i]))
|
||||
{
|
||||
c = close[i];
|
||||
lastValidClose = close[i];
|
||||
}
|
||||
else if (double.IsFinite(lastValidClose))
|
||||
{
|
||||
c = lastValidClose;
|
||||
}
|
||||
else
|
||||
{
|
||||
c = 0;
|
||||
}
|
||||
|
||||
// Handle NaN/Infinity for volume - use input if finite, else last valid, else 0 (neutral)
|
||||
double v;
|
||||
if (double.IsFinite(volume[i]))
|
||||
{
|
||||
v = volume[i];
|
||||
lastValidVolume = volume[i];
|
||||
}
|
||||
else if (double.IsFinite(lastValidVolume))
|
||||
{
|
||||
v = lastValidVolume;
|
||||
}
|
||||
else
|
||||
{
|
||||
v = 0;
|
||||
}
|
||||
|
||||
// Calculate OBV
|
||||
if (i == 0)
|
||||
{
|
||||
obv = 0; // First bar, no comparison
|
||||
}
|
||||
else
|
||||
{
|
||||
if (c > prevClose)
|
||||
{
|
||||
obv += v;
|
||||
}
|
||||
else if (c < prevClose)
|
||||
{
|
||||
obv -= v;
|
||||
}
|
||||
}
|
||||
|
||||
// EMA Fast
|
||||
emaFast = Math.FusedMultiplyAdd(alphaFast, obv - emaFast, emaFast);
|
||||
if (warmupFast)
|
||||
{
|
||||
eFast *= betaFast;
|
||||
double comp = 1.0 / (1.0 - eFast);
|
||||
outputFast[i] = comp * emaFast;
|
||||
if (eFast <= 1e-10)
|
||||
{
|
||||
warmupFast = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
outputFast[i] = emaFast;
|
||||
}
|
||||
|
||||
// EMA Slow
|
||||
emaSlow = Math.FusedMultiplyAdd(alphaSlow, obv - emaSlow, emaSlow);
|
||||
if (warmupSlow)
|
||||
{
|
||||
eSlow *= betaSlow;
|
||||
double comp = 1.0 / (1.0 - eSlow);
|
||||
outputSlow[i] = comp * emaSlow;
|
||||
if (eSlow <= 1e-10)
|
||||
{
|
||||
warmupSlow = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
outputSlow[i] = emaSlow;
|
||||
}
|
||||
|
||||
prevClose = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user