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:
Miha Kralj
2026-01-28 08:56:41 -08:00
parent a9e72dae0d
commit c7e55c2f1e
29 changed files with 5155 additions and 8 deletions
+198
View File
@@ -0,0 +1,198 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class IiiIndicatorTests
{
[Fact]
public void IiiIndicator_Constructor_SetsDefaults()
{
var indicator = new IiiIndicator();
Assert.Equal("III - Intraday Intensity Index", indicator.Name);
Assert.Equal(21, indicator.Period);
Assert.False(indicator.Cumulative);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
Assert.Equal(21, indicator.MinHistoryDepths);
}
[Fact]
public void IiiIndicator_ShortName_ReflectsPeriod()
{
var indicator = new IiiIndicator { Period = 14 };
Assert.Equal("III(14)", indicator.ShortName);
}
[Fact]
public void IiiIndicator_ShortName_ShowsCumulativeMode()
{
var indicator = new IiiIndicator { Period = 14, Cumulative = true };
Assert.Equal("III(14,Cum)", indicator.ShortName);
}
[Fact]
public void IiiIndicator_MinHistoryDepths_EqualsPeriod()
{
var indicator = new IiiIndicator { Period = 30 };
Assert.Equal(30, indicator.MinHistoryDepths);
Assert.Equal(30, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void IiiIndicator_Initialize_CreatesInternalIii()
{
var indicator = new IiiIndicator();
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void IiiIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new IiiIndicator();
indicator.Initialize();
// Add historical data
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i, 1000 + (i * 100));
// Process update for each bar to simulate history loading
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
// Line series should have a value
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val));
}
[Fact]
public void IiiIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new IiiIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i, 1000 + (i * 100));
}
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// Add new bar
indicator.HistoricalData.AddBar(now.AddMinutes(30), 130, 140, 120, 135, 1500);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(2, indicator.LinesSeries[0].Count);
}
[Fact]
public void IiiIndicator_Value_IsFinite()
{
var indicator = new IiiIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 50; i++)
{
// Create varying price patterns with price ranges
double open = 100 + i;
double high = open + 10 + (i % 5);
double low = open - 5;
double close = (i % 2 == 0) ? high - 1 : low + 1;
double volume = 1000 + (i * 100);
indicator.HistoricalData.AddBar(now.AddMinutes(i), open, high, low, close, volume);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val), $"III value {val} should be finite");
}
[Fact]
public void IiiIndicator_PositiveValue_OnCloseNearHigh()
{
var indicator = new IiiIndicator { Period = 3 };
indicator.Initialize();
var now = DateTime.UtcNow;
// Add bars with close consistently near high (buying pressure)
for (int i = 0; i < 10; i++)
{
double basePrice = 100 + i;
double low = basePrice - 10;
double high = basePrice + 10;
double close = high - 1; // Close near high
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, high, low, close, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(val > 0, $"III should be positive when close is near high, got {val}");
}
[Fact]
public void IiiIndicator_NegativeValue_OnCloseNearLow()
{
var indicator = new IiiIndicator { Period = 3 };
indicator.Initialize();
var now = DateTime.UtcNow;
// Add bars with close consistently near low (selling pressure)
for (int i = 0; i < 10; i++)
{
double basePrice = 100 + i;
double low = basePrice - 10;
double high = basePrice + 10;
double close = low + 1; // Close near low
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, high, low, close, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(val < 0, $"III should be negative when close is near low, got {val}");
}
[Fact]
public void IiiIndicator_CumulativeMode_ProducesDifferentResults()
{
var indicator1 = new IiiIndicator { Period = 5, Cumulative = false };
var indicator2 = new IiiIndicator { Period = 5, Cumulative = true };
indicator1.Initialize();
indicator2.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
double basePrice = 100 + i;
double high = basePrice + 5;
double low = basePrice - 5;
double close = (i % 2 == 0) ? high - 1 : low + 1;
indicator1.HistoricalData.AddBar(now.AddMinutes(i), basePrice, high, low, close, 1000);
indicator2.HistoricalData.AddBar(now.AddMinutes(i), basePrice, high, low, close, 1000);
indicator1.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicator2.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double val1 = indicator1.LinesSeries[0].GetValue(0);
double val2 = indicator2.LinesSeries[0].GetValue(0);
// Different modes should produce different results
Assert.NotEqual(val1, val2);
Assert.True(double.IsFinite(val1));
Assert.True(double.IsFinite(val2));
}
}
+54
View File
@@ -0,0 +1,54 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class IiiIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 10, 1, 500, 1, 0)]
public int Period { get; set; } = 21;
[InputParameter("Cumulative Mode", sortIndex: 11)]
public bool Cumulative { get; set; }
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Iii _iii = null!;
private readonly LineSeries _series;
public int MinHistoryDepths => Period;
int IWatchlistIndicator.MinHistoryDepths => Period;
public override string ShortName => $"III({Period}{(Cumulative ? ",Cum" : "")})";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/volume/iii/Iii.Quantower.cs";
public IiiIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "III - Intraday Intensity Index";
Description = "Intraday Intensity Index measures buying/selling pressure using the position of the close within the day's range, weighted by volume";
_series = new LineSeries(name: "III", color: Color.Cyan, width: 2, style: LineStyle.Solid);
AddLineSeries(_series);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_iii = new Iii(Period, Cumulative);
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
TBar bar = this.GetInputBar(args);
TValue result = _iii.Update(bar, args.IsNewBar());
_series.SetValue(result.Value, _iii.IsHot, ShowColdValues);
}
}
+411
View File
@@ -0,0 +1,411 @@
using Xunit;
namespace QuanTAlib.Tests;
public class IiiTests
{
private const int DefaultPeriod = 14;
[Fact]
public void Constructor_DefaultParameters_CreatesValidIndicator()
{
var iii = new Iii();
Assert.Equal($"Iii({DefaultPeriod})", iii.Name);
Assert.Equal(DefaultPeriod, iii.WarmupPeriod);
Assert.False(iii.IsHot);
}
[Fact]
public void Constructor_CustomParameters_CreatesValidIndicator()
{
var iii = new Iii(period: 20, cumulative: true);
Assert.Equal("Iii(20,Cum)", iii.Name);
Assert.Equal(20, iii.WarmupPeriod);
}
[Fact]
public void Constructor_InvalidPeriod_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() => new Iii(period: 0));
Assert.Throws<ArgumentException>(() => new Iii(period: -1));
}
[Fact]
public void Update_WithTBar_ReturnsValidValue()
{
var iii = new Iii();
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000000);
var result = iii.Update(bar);
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Update_WithTValue_ThrowsNotSupportedException()
{
var iii = new Iii();
var value = new TValue(DateTime.UtcNow, 100);
Assert.Throws<NotSupportedException>(() => iii.Update(value));
}
[Fact]
public void Update_CloseAtHigh_ReturnsPositiveValue()
{
var iii = new Iii(period: 1);
// Close at high means position multiplier = +1
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 110, 100000);
var result = iii.Update(bar);
Assert.True(result.Value > 0, "Close at high should result in positive III");
}
[Fact]
public void Update_CloseAtLow_ReturnsNegativeValue()
{
var iii = new Iii(period: 1);
// Close at low means position multiplier = -1
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 90, 100000);
var result = iii.Update(bar);
Assert.True(result.Value < 0, "Close at low should result in negative III");
}
[Fact]
public void Update_CloseAtMidpoint_ReturnsZero()
{
var iii = new Iii(period: 1);
// Close at midpoint means position multiplier = 0
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 100, 100000);
var result = iii.Update(bar);
Assert.Equal(0.0, result.Value, 10);
}
[Fact]
public void Update_IsNewTrue_AdvancesState()
{
var iii = new Iii();
var bar1 = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000000);
var result1 = iii.Update(bar1, isNew: true);
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 105, 115, 95, 110, 1100000);
var result2 = iii.Update(bar2, isNew: true);
Assert.NotEqual(result1.Time, result2.Time);
}
[Fact]
public void Update_IsNewFalse_UpdatesCurrentBar()
{
var iii = new Iii();
var time = DateTime.UtcNow;
var bar1 = new TBar(time, 100, 110, 90, 105, 1000000);
iii.Update(bar1, isNew: true);
var bar2 = new TBar(time.AddMinutes(1), 105, 115, 95, 110, 1100000);
var result1 = iii.Update(bar2, isNew: true);
// Update same bar with different values
var bar2Updated = new TBar(time.AddMinutes(1), 105, 115, 95, 115, 1200000);
var result2 = iii.Update(bar2Updated, isNew: false);
Assert.Equal(result1.Time, result2.Time);
Assert.NotEqual(result1.Value, result2.Value);
}
[Fact]
public void Update_IterativeCorrections_UpdatesCurrentValue()
{
var iii = new Iii(period: 3);
var time = DateTime.UtcNow;
// Build up some state
iii.Update(new TBar(time, 100, 110, 90, 105, 100000), isNew: true);
iii.Update(new TBar(time.AddMinutes(1), 105, 115, 95, 110, 110000), isNew: true);
// Original bar 3
var bar3 = new TBar(time.AddMinutes(2), 110, 120, 100, 115, 120000);
var originalResult = iii.Update(bar3, isNew: true);
// Make a correction with different values
var correctionBar = new TBar(time.AddMinutes(2), 100, 150, 80, 80, 200000);
var correctedResult = iii.Update(correctionBar, isNew: false);
// Values should differ due to different bar data
Assert.NotEqual(originalResult.Value, correctedResult.Value);
Assert.True(double.IsFinite(correctedResult.Value));
}
[Fact]
public void Update_WarmupPeriod_IsHotBecomesTrueAfterWarmup()
{
var iii = new Iii(period: 3);
var time = DateTime.UtcNow;
Assert.False(iii.IsHot);
iii.Update(new TBar(time, 100, 110, 90, 105, 100000), isNew: true);
Assert.False(iii.IsHot);
iii.Update(new TBar(time.AddMinutes(1), 105, 115, 95, 110, 110000), isNew: true);
Assert.False(iii.IsHot);
iii.Update(new TBar(time.AddMinutes(2), 110, 120, 100, 115, 120000), isNew: true);
// After period bars, should be hot
Assert.True(iii.IsHot);
}
[Fact]
public void Update_WithNaN_UsesLastValidValue()
{
var iii = new Iii(period: 3);
// Process some valid bars first
iii.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 105, 100000));
iii.Update(new TBar(DateTime.UtcNow.AddMinutes(1), 105, 115, 95, 110, 110000));
// Process bar with NaN close (will cause NaN in calculation)
var nanBar = new TBar(DateTime.UtcNow.AddMinutes(2), double.NaN, 120, 100, double.NaN, 120000);
var result = iii.Update(nanBar);
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Update_ZeroPriceRange_ReturnsZero()
{
var iii = new Iii(period: 1);
// When high = low, range is 0, position multiplier is 0
var bar = new TBar(DateTime.UtcNow, 100, 100, 100, 100, 100000);
var result = iii.Update(bar);
Assert.Equal(0.0, result.Value);
}
[Fact]
public void Update_ZeroVolume_UsesMinimumVolume()
{
var iii = new Iii(period: 1);
// Zero volume should be treated as minimum of 1
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 0);
var result = iii.Update(bar);
Assert.True(double.IsFinite(result.Value));
// Position multiplier = (2*105 - 110 - 90) / 20 = 10/20 = 0.5
// Raw III = 0.5 * 1 = 0.5
Assert.Equal(0.5, result.Value, 10);
}
[Fact]
public void Update_CumulativeMode_AccumulatesValues()
{
var iii = new Iii(period: 1, cumulative: true);
var time = DateTime.UtcNow;
// First bar with positive III
var result1 = iii.Update(new TBar(time, 100, 110, 90, 110, 100), isNew: true);
double firstValue = result1.Value;
// Second bar with positive III
var result2 = iii.Update(new TBar(time.AddMinutes(1), 100, 110, 90, 110, 100), isNew: true);
// Cumulative should add up
Assert.Equal(firstValue * 2, result2.Value, 10);
}
[Fact]
public void Reset_ClearsState()
{
var iii = new Iii(period: 3);
var time = DateTime.UtcNow;
// Process some bars
iii.Update(new TBar(time, 100, 110, 90, 105, 100000), isNew: true);
iii.Update(new TBar(time.AddMinutes(1), 105, 115, 95, 110, 110000), isNew: true);
iii.Update(new TBar(time.AddMinutes(2), 110, 120, 100, 115, 120000), isNew: true);
Assert.True(iii.IsHot);
iii.Reset();
Assert.False(iii.IsHot);
Assert.Equal(default, iii.Last);
}
[Fact]
public void BatchCalculate_MatchesStreaming()
{
var bars = new TBarSeries();
var gbm = new GBM(seed: 42);
for (int i = 0; i < 100; i++)
{
bars.Add(gbm.Next());
}
// Streaming
var iii = new Iii();
var streamingValues = new List<double>();
foreach (var bar in bars)
{
streamingValues.Add(iii.Update(bar).Value);
}
// Batch
var batchResult = Iii.Calculate(bars);
Assert.Equal(bars.Count, batchResult.Count);
for (int i = 0; i < bars.Count; i++)
{
Assert.Equal(streamingValues[i], batchResult[i].Value, 10);
}
}
[Fact]
public void SpanCalculate_MatchesStreaming()
{
var bars = new TBarSeries();
var gbm = new GBM(seed: 42);
for (int i = 0; i < 100; i++)
{
bars.Add(gbm.Next());
}
// Streaming
var iii = new Iii();
var streamingValues = new List<double>();
foreach (var bar in bars)
{
streamingValues.Add(iii.Update(bar).Value);
}
// Span
var high = bars.High.Values.ToArray();
var low = bars.Low.Values.ToArray();
var close = bars.Close.Values.ToArray();
var volume = bars.Volume.Values.ToArray();
var spanValues = new double[bars.Count];
Iii.Calculate(high, low, close, volume, spanValues);
for (int i = 0; i < bars.Count; i++)
{
Assert.Equal(streamingValues[i], spanValues[i], 10);
}
}
[Fact]
public void SpanCalculate_InvalidLengths_ThrowsArgumentException()
{
var high = new double[100];
var low = new double[99]; // Different length
var close = new double[100];
var volume = new double[100];
var output = new double[100];
Assert.Throws<ArgumentException>(() => Iii.Calculate(high, low, close, volume, output));
}
[Fact]
public void SpanCalculate_InvalidPeriod_ThrowsArgumentException()
{
var high = new double[100];
var low = new double[100];
var close = new double[100];
var volume = new double[100];
var output = new double[100];
Assert.Throws<ArgumentException>(() => Iii.Calculate(high, low, close, volume, output, period: 0));
}
[Fact]
public void SpanCalculate_LargeData_UsesArrayPool()
{
int size = 1000; // > 256 threshold
var high = new double[size];
var low = new double[size];
var close = new double[size];
var volume = new double[size];
var output = new double[size];
for (int i = 0; i < size; i++)
{
high[i] = 110 + i * 0.1;
low[i] = 90 + i * 0.1;
close[i] = 100 + i * 0.1;
volume[i] = 100000;
}
// Should not throw
Iii.Calculate(high, low, close, volume, output);
Assert.True(double.IsFinite(output[size - 1]));
}
[Fact]
public void SpanCalculate_CumulativeMode_MatchesStreaming()
{
var bars = new TBarSeries();
var gbm = new GBM(seed: 42);
for (int i = 0; i < 50; i++)
{
bars.Add(gbm.Next());
}
// Streaming cumulative
var iii = new Iii(period: 14, cumulative: true);
var streamingValues = new List<double>();
foreach (var bar in bars)
{
streamingValues.Add(iii.Update(bar).Value);
}
// Span cumulative
var high = bars.High.Values.ToArray();
var low = bars.Low.Values.ToArray();
var close = bars.Close.Values.ToArray();
var volume = bars.Volume.Values.ToArray();
var spanValues = new double[bars.Count];
Iii.Calculate(high, low, close, volume, spanValues, period: 14, cumulative: true);
for (int i = 0; i < bars.Count; i++)
{
Assert.Equal(streamingValues[i], spanValues[i], 10);
}
}
[Fact]
public void Event_PubFiresOnUpdate()
{
var iii = new Iii();
TValue? receivedValue = null;
bool receivedIsNew = false;
iii.Pub += (object? sender, in TValueEventArgs args) =>
{
receivedValue = args.Value;
receivedIsNew = args.IsNew;
};
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000000);
iii.Update(bar, isNew: true);
Assert.NotNull(receivedValue);
Assert.True(receivedIsNew);
}
[Fact]
public void PositionMultiplier_CalculatesCorrectly()
{
// Test specific position multiplier values
var iii = new Iii(period: 1);
// Close at 75% of range (high=110, low=90, close=105)
// Position = (2*105 - 110 - 90) / (110-90) = 10/20 = 0.5
var bar1 = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 200);
var result1 = iii.Update(bar1);
Assert.Equal(0.5 * 200, result1.Value, 10); // 0.5 * volume
iii.Reset();
// Close at 25% of range (high=110, low=90, close=95)
// Position = (2*95 - 110 - 90) / (110-90) = -10/20 = -0.5
var bar2 = new TBar(DateTime.UtcNow, 100, 110, 90, 95, 200);
var result2 = iii.Update(bar2);
Assert.Equal(-0.5 * 200, result2.Value, 10); // -0.5 * volume
}
}
+297
View File
@@ -0,0 +1,297 @@
using Xunit;
namespace QuanTAlib.Tests;
public class IiiValidationTests
{
private const int DataPoints = 5000;
private const int DefaultPeriod = 14;
private static readonly double SkenderTolerance = ValidationHelper.SkenderTolerance;
private static TBarSeries GenerateTestData(int seed = 42)
{
var bars = new TBarSeries();
var gbm = new GBM(seed: seed);
for (int i = 0; i < DataPoints; i++)
{
bars.Add(gbm.Next());
}
return bars;
}
[Fact]
public void Iii_BatchMode_MatchesStreamingMode()
{
var bars = GenerateTestData();
var iii = new Iii(DefaultPeriod);
// Streaming mode
var streamingResults = new List<double>();
foreach (var bar in bars)
{
streamingResults.Add(iii.Update(bar).Value);
}
// Batch mode
var batchResults = Iii.Calculate(bars, DefaultPeriod);
// Compare results
Assert.Equal(bars.Count, batchResults.Count);
for (int i = 0; i < bars.Count; i++)
{
Assert.Equal(streamingResults[i], batchResults[i].Value, 8);
}
}
[Fact]
public void Iii_SpanMode_MatchesStreamingMode()
{
var bars = GenerateTestData();
var iii = new Iii(DefaultPeriod);
// Streaming mode
var streamingResults = new List<double>();
foreach (var bar in bars)
{
streamingResults.Add(iii.Update(bar).Value);
}
// Span mode
var high = bars.High.Values.ToArray();
var low = bars.Low.Values.ToArray();
var close = bars.Close.Values.ToArray();
var volume = bars.Volume.Values.ToArray();
var spanResults = new double[bars.Count];
Iii.Calculate(high, low, close, volume, spanResults, DefaultPeriod);
// Compare results
for (int i = 0; i < bars.Count; i++)
{
Assert.Equal(streamingResults[i], spanResults[i], 8);
}
}
[Fact]
public void Iii_CumulativeMode_BatchMatchesStreaming()
{
var bars = GenerateTestData();
var iii = new Iii(DefaultPeriod, cumulative: true);
// Streaming mode
var streamingResults = new List<double>();
foreach (var bar in bars)
{
streamingResults.Add(iii.Update(bar).Value);
}
// Batch mode
var batchResults = Iii.Calculate(bars, DefaultPeriod, cumulative: true);
// Compare results
Assert.Equal(bars.Count, batchResults.Count);
for (int i = 0; i < bars.Count; i++)
{
Assert.Equal(streamingResults[i], batchResults[i].Value, 8);
}
}
[Fact]
public void Iii_CumulativeMode_SpanMatchesStreaming()
{
var bars = GenerateTestData();
var iii = new Iii(DefaultPeriod, cumulative: true);
// Streaming mode
var streamingResults = new List<double>();
foreach (var bar in bars)
{
streamingResults.Add(iii.Update(bar).Value);
}
// Span mode
var high = bars.High.Values.ToArray();
var low = bars.Low.Values.ToArray();
var close = bars.Close.Values.ToArray();
var volume = bars.Volume.Values.ToArray();
var spanResults = new double[bars.Count];
Iii.Calculate(high, low, close, volume, spanResults, DefaultPeriod, cumulative: true);
// Compare results
for (int i = 0; i < bars.Count; i++)
{
Assert.Equal(streamingResults[i], spanResults[i], 8);
}
}
[Fact]
public void Iii_AllThreeModesMatch_WithinTolerance()
{
var bars = GenerateTestData();
var iii = new Iii(DefaultPeriod);
// Streaming mode
var streamingResults = new List<double>();
foreach (var bar in bars)
{
streamingResults.Add(iii.Update(bar).Value);
}
// Batch mode
var batchResults = Iii.Calculate(bars, DefaultPeriod);
// Span mode
var high = bars.High.Values.ToArray();
var low = bars.Low.Values.ToArray();
var close = bars.Close.Values.ToArray();
var volume = bars.Volume.Values.ToArray();
var spanResults = new double[bars.Count];
Iii.Calculate(high, low, close, volume, spanResults, DefaultPeriod);
// All three should match
for (int i = 0; i < bars.Count; i++)
{
double streaming = streamingResults[i];
double batch = batchResults[i].Value;
double span = spanResults[i];
Assert.Equal(streaming, batch, 8);
Assert.Equal(streaming, span, 8);
Assert.Equal(batch, span, 8);
}
}
[Fact]
public void Iii_Last100Values_AllModesMatch()
{
var bars = GenerateTestData();
var iii = new Iii(DefaultPeriod);
// Streaming mode
var streamingResults = new List<double>();
foreach (var bar in bars)
{
streamingResults.Add(iii.Update(bar).Value);
}
// Batch mode
var batchResults = Iii.Calculate(bars, DefaultPeriod);
// Span mode
var high = bars.High.Values.ToArray();
var low = bars.Low.Values.ToArray();
var close = bars.Close.Values.ToArray();
var volume = bars.Volume.Values.ToArray();
var spanResults = new double[bars.Count];
Iii.Calculate(high, low, close, volume, spanResults, DefaultPeriod);
// Focus on last 100 values (well past warmup)
int startIdx = bars.Count - 100;
for (int i = startIdx; i < bars.Count; i++)
{
Assert.Equal(streamingResults[i], batchResults[i].Value, SkenderTolerance);
Assert.Equal(streamingResults[i], spanResults[i], SkenderTolerance);
}
}
[Fact]
public void Iii_DifferentPeriods_ProduceDifferentResults()
{
var bars = GenerateTestData();
var iii10 = new Iii(10);
var iii20 = new Iii(20);
var iii50 = new Iii(50);
var results10 = new List<double>();
var results20 = new List<double>();
var results50 = new List<double>();
foreach (var bar in bars)
{
results10.Add(iii10.Update(bar).Value);
results20.Add(iii20.Update(bar).Value);
results50.Add(iii50.Update(bar).Value);
}
// After warmup, results should differ
int testIdx = 100;
Assert.NotEqual(results10[testIdx], results20[testIdx]);
Assert.NotEqual(results20[testIdx], results50[testIdx]);
Assert.NotEqual(results10[testIdx], results50[testIdx]);
}
[Fact]
public void Iii_SmoothedVsCumulative_ProduceDifferentResults()
{
var bars = GenerateTestData();
var iiiSmoothed = new Iii(DefaultPeriod, cumulative: false);
var iiiCumulative = new Iii(DefaultPeriod, cumulative: true);
var smoothedResults = new List<double>();
var cumulativeResults = new List<double>();
foreach (var bar in bars)
{
smoothedResults.Add(iiiSmoothed.Update(bar).Value);
cumulativeResults.Add(iiiCumulative.Update(bar).Value);
}
// After first bar, results should differ (cumulative grows, smoothed averages)
for (int i = DefaultPeriod; i < bars.Count; i++)
{
Assert.NotEqual(smoothedResults[i], cumulativeResults[i]);
}
}
[Fact]
public void Iii_PositionMultiplier_ValuesBounded()
{
// III raw values should be bounded by volume since position multiplier is [-1, +1]
var bars = GenerateTestData();
var iii = new Iii(period: 1); // Period 1 to see raw values
foreach (var bar in bars)
{
var result = iii.Update(bar);
double vol = Math.Max(bar.Volume, 1.0);
// With period 1, result equals raw III
// Position multiplier bounded [-1, +1], so result bounded [-vol, +vol]
Assert.True(result.Value <= vol && result.Value >= -vol,
$"III value {result.Value} exceeds volume bounds {vol}");
}
}
[Fact]
public void Iii_ConsistentResults_MultipleSeedTests()
{
// Test with multiple seeds to ensure consistency
int[] seeds = { 42, 123, 456, 789, 1000 };
foreach (int seed in seeds)
{
var bars = GenerateTestData(seed);
var iii = new Iii(DefaultPeriod);
// Streaming
var streamingResults = new List<double>();
foreach (var bar in bars)
{
streamingResults.Add(iii.Update(bar).Value);
}
// Batch
var batchResults = Iii.Calculate(bars, DefaultPeriod);
// Should match for any seed
for (int i = bars.Count - 50; i < bars.Count; i++)
{
Assert.Equal(streamingResults[i], batchResults[i].Value, 8);
}
}
}
}
+351
View File
@@ -0,0 +1,351 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// III: Intraday Intensity Index
/// A volume-based indicator that measures buying and selling pressure by
/// analyzing where the close falls within the high-low range, weighted by volume.
/// Values range from -1 (close at low) to +1 (close at high) times volume.
/// </summary>
/// <remarks>
/// The III calculation process:
/// 1. Calculate position multiplier = (2 * Close - High - Low) / (High - Low)
/// 2. Calculate raw III = position multiplier * volume
/// 3. Apply SMA smoothing to raw III
/// 4. Optionally accumulate values in cumulative mode
///
/// Key characteristics:
/// - Positive values indicate accumulation (close near high)
/// - Negative values indicate distribution (close near low)
/// - Combines price position with volume for confirmation
/// - Can be used in smoothed or cumulative mode
///
/// Sources:
/// David Bostian - Original developer
/// https://github.com/mihakralj/pinescript/blob/main/indicators/volume/iii.md
/// </remarks>
[SkipLocalsInit]
public sealed class Iii : ITValuePublisher
{
[StructLayout(LayoutKind.Auto)]
private record struct State
{
public double Sum;
public double CumulativeValue;
public int Head;
public int Count;
public double LastValidValue;
}
private State _s;
private State _ps;
private readonly int _period;
private readonly bool _cumulative;
private readonly double[] _buffer;
public string Name { get; }
public int WarmupPeriod { get; }
public TValue Last { get; private set; }
public bool IsHot { get; private set; }
public event TValuePublishedHandler? Pub;
/// <summary>
/// Initializes a new instance of the Iii class.
/// </summary>
/// <param name="period">The smoothing period for SMA calculation (default: 14)</param>
/// <param name="cumulative">Whether to accumulate values (default: false)</param>
/// <exception cref="ArgumentException">Thrown when period is less than 1</exception>
public Iii(int period = 14, bool cumulative = false)
{
if (period < 1)
{
throw new ArgumentException("Period must be >= 1", nameof(period));
}
_period = period;
_cumulative = cumulative;
_buffer = new double[period];
WarmupPeriod = period;
Name = cumulative ? $"Iii({period},Cum)" : $"Iii({period})";
_s = new State { LastValidValue = 0.0 };
_ps = _s;
}
/// <summary>
/// Updates the indicator with a new bar.
/// </summary>
/// <param name="bar">The bar data containing High, Low, Close, and Volume</param>
/// <param name="isNew">Whether this is a new bar or an update to the current bar</param>
/// <returns>The calculated III value</returns>
[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, 1.0); // Ensure minimum volume of 1
// Calculate price range
double range = high - low;
// Calculate position multiplier: where close falls in the range
// +1 when close = high, -1 when close = low, 0 when close = midpoint
double positionMultiplier = range > 0 ? (2.0 * close - high - low) / range : 0.0;
// Calculate raw III
double rawIii = positionMultiplier * volume;
// Handle NaN/Infinity
if (!double.IsFinite(rawIii))
{
rawIii = s.LastValidValue;
}
else
{
s.LastValidValue = rawIii;
}
// Update cumulative value
if (isNew)
{
s.CumulativeValue += rawIii;
}
// SMA calculation using ring buffer (for non-cumulative mode)
if (isNew && s.Count >= _period)
{
s.Sum -= _buffer[s.Head];
}
if (isNew)
{
_buffer[s.Head] = rawIii;
s.Sum += rawIii;
s.Head = (s.Head + 1) % _period;
if (s.Count < _period)
{
s.Count++;
}
}
else
{
// For bar correction, update the previous value in buffer
int prevHead = (s.Head + _period - 1) % _period;
double oldValue = _buffer[prevHead];
s.Sum = s.Sum - oldValue + rawIii;
_buffer[prevHead] = rawIii;
// Recalculate cumulative by removing old and adding new
s.CumulativeValue = s.CumulativeValue - oldValue + rawIii;
}
// Calculate result based on mode
double result;
if (_cumulative)
{
result = s.CumulativeValue;
}
else
{
// For SMA: divide by s.Count during warmup, _period once fully warmed
int divisor = s.Count < _period ? s.Count : _period;
result = divisor > 0 ? s.Sum / divisor : 0.0;
}
_s = s;
IsHot = s.Count >= _period;
Last = new TValue(bar.Time, result);
Pub?.Invoke(this, new TValueEventArgs { Value = Last, IsNew = isNew });
return Last;
}
/// <summary>
/// TValue input is not supported for III - requires TBar (OHLCV) data.
/// </summary>
#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("III requires TBar (OHLCV) data. Use Update(TBar) instead.");
}
/// <summary>
/// Updates III with a bar series.
/// </summary>
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>
/// Resets the indicator to its initial state.
/// </summary>
public void Reset()
{
_s = new State { LastValidValue = 0.0 };
_ps = _s;
Array.Clear(_buffer);
IsHot = false;
Last = default;
}
/// <summary>
/// Calculates III for a series of bars.
/// </summary>
/// <param name="bars">The input bar series</param>
/// <param name="period">The smoothing period</param>
/// <param name="cumulative">Whether to use cumulative mode</param>
/// <returns>A TSeries containing the III values</returns>
public static TSeries Calculate(TBarSeries bars, int period = 14, bool cumulative = false)
{
if (bars.Count == 0)
{
return [];
}
var t = bars.Open.Times.ToArray();
var v = new double[bars.Count];
Calculate(bars.High.Values, bars.Low.Values, bars.Close.Values, bars.Volume.Values, v, period, cumulative);
return new TSeries(t, v);
}
/// <summary>
/// Calculates III values using span-based processing.
/// </summary>
/// <param name="high">Source high prices</param>
/// <param name="low">Source low prices</param>
/// <param name="close">Source close prices</param>
/// <param name="volume">Source volumes</param>
/// <param name="output">Output span for III values</param>
/// <param name="period">The smoothing period</param>
/// <param name="cumulative">Whether to use cumulative mode</param>
/// <exception cref="ArgumentException">Thrown when spans have different lengths</exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Calculate(ReadOnlySpan<double> high, ReadOnlySpan<double> low,
ReadOnlySpan<double> close, ReadOnlySpan<double> volume, Span<double> output,
int period = 14, bool cumulative = false)
{
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 (period < 1)
{
throw new ArgumentException("Period must be >= 1", nameof(period));
}
int length = high.Length;
if (length == 0)
{
return;
}
const int StackallocThreshold = 256;
double[]? rentedBuffer = null;
scoped Span<double> rawIii;
if (length <= StackallocThreshold)
{
rawIii = stackalloc double[length];
}
else
{
rentedBuffer = System.Buffers.ArrayPool<double>.Shared.Rent(length);
rawIii = rentedBuffer.AsSpan(0, length);
}
try
{
// Calculate raw III values
for (int i = 0; i < length; i++)
{
double range = high[i] - low[i];
double vol = Math.Max(volume[i], 1.0);
double positionMultiplier = range > 0 ? (2.0 * close[i] - high[i] - low[i]) / range : 0.0;
rawIii[i] = positionMultiplier * vol;
if (!double.IsFinite(rawIii[i]))
{
rawIii[i] = i > 0 ? rawIii[i - 1] : 0.0;
}
}
if (cumulative)
{
// Cumulative mode
double cumulativeSum = 0;
for (int i = 0; i < length; i++)
{
cumulativeSum += rawIii[i];
output[i] = cumulativeSum;
}
}
else
{
// Apply SMA smoothing
double sum = 0;
for (int i = 0; i < length; i++)
{
sum += rawIii[i];
if (i >= period)
{
sum -= rawIii[i - period];
output[i] = sum / period;
}
else
{
// During warmup, divide by actual sample count
output[i] = sum / (i + 1);
}
}
}
}
finally
{
if (rentedBuffer != null)
{
System.Buffers.ArrayPool<double>.Shared.Return(rentedBuffer);
}
}
}
}
+135
View File
@@ -0,0 +1,135 @@
# III: Intraday Intensity Index
> "Where the close lands within the day's range tells you who won the battle—bulls or bears. Volume tells you how hard they fought."
The Intraday Intensity Index (III) measures buying and selling pressure by analyzing where the close price falls within the high-low range, weighted by volume. Originally developed by David Bostian, this indicator quantifies whether money is flowing into or out of a security on an intraday basis. Values range from -1 (close at low, maximum selling pressure) to +1 (close at high, maximum buying pressure), multiplied by volume for magnitude.
## Historical Context
David Bostian developed the Intraday Intensity Index in the 1980s as a way to measure money flow within the trading day. The concept builds on the intuition that the closing price's position within the day's range reveals whether buyers or sellers controlled the session.
Unlike indicators that only look at price direction or volume alone, III combines both: a close near the high with heavy volume suggests strong accumulation, while a close near the low with heavy volume indicates distribution. This makes III particularly useful for confirming price trends and identifying potential reversals through divergences.
## Architecture & Physics
The indicator operates in two modes: smoothed (default) and cumulative.
### 1. Position Multiplier
The core calculation determines where the close falls within the high-low range:
$$
PM_t = \begin{cases}
\frac{2 \times C_t - H_t - L_t}{H_t - L_t} & \text{if } H_t \neq L_t \\
0 & \text{if } H_t = L_t
\end{cases}
$$
The position multiplier ranges from:
- **+1**: Close equals High (maximum bullish)
- **0**: Close at midpoint (neutral)
- **-1**: Close equals Low (maximum bearish)
### 2. Raw Intensity
The raw III value multiplies position by volume:
$$
III_{raw,t} = PM_t \times V_t
$$
This weights the directional signal by the conviction behind it (volume).
### 3. Smoothing / Accumulation
In **smoothed mode** (default), a Simple Moving Average is applied:
$$
III_t = \frac{1}{n} \sum_{i=0}^{n-1} III_{raw,t-i}
$$
In **cumulative mode**, values are accumulated over time:
$$
III_{cum,t} = \sum_{i=0}^{t} III_{raw,i}
$$
## Mathematical Foundation
### Position Multiplier Derivation
The formula $(2C - H - L) / (H - L)$ can be rewritten as:
$$
PM = \frac{(C - L) - (H - C)}{H - L} = \frac{2(C - M)}{H - L}
$$
where $M = (H + L) / 2$ is the midpoint. This shows that PM measures how far the close deviates from the midpoint, normalized by the range.
### Interpretation
- **PM > 0**: Close above midpoint → buyers dominated
- **PM < 0**: Close below midpoint → sellers dominated
- **PM × V**: Large volume amplifies the signal
### Smoothed vs Cumulative
- **Smoothed**: Shows recent average buying/selling pressure; oscillates around zero
- **Cumulative**: Shows cumulative money flow over time; trends with price
## Performance Profile
### Operation Count (Streaming Mode, Scalar)
| Operation | Count | Cost (cycles) | Subtotal |
| :--- | :---: | :---: | :---: |
| SUB | 3 | 1 | 3 |
| MUL | 1 | 3 | 3 |
| DIV | 1 | 15 | 15 |
| CMP | 1 | 1 | 1 |
| Ring buffer update | 1 | ~5 | 5 |
| **Total** | **7** | — | **~27 cycles** |
The algorithm is simple and efficient with O(1) streaming complexity.
### Quality Metrics
| Metric | Score | Notes |
| :--- | :---: | :--- |
| **Accuracy** | 9/10 | Mathematically exact |
| **Timeliness** | 7/10 | SMA smoothing adds lag |
| **Simplicity** | 9/10 | Intuitive formula |
| **Usefulness** | 8/10 | Good for divergence analysis |
## Validation
III is a relatively uncommon indicator in mainstream libraries, but the algorithm is straightforward.
| Library | Status | Notes |
| :--- | :---: | :--- |
| **TA-Lib** | N/A | Not implemented |
| **Skender** | N/A | Not implemented |
| **Tulip** | N/A | Not implemented |
| **Ooples** | N/A | Not implemented |
| **PineScript** | ✅ | Reference implementation |
Validation focuses on internal consistency (streaming vs batch vs span modes).
## Common Pitfalls
1. **Warmup Period**: The indicator requires `period` bars before the SMA is fully primed. Before warmup, values are averaged over available data.
2. **Zero Range**: When High equals Low (flat bars), the position multiplier is undefined. The implementation returns 0 in this case.
3. **Volume Importance**: III is volume-weighted, so low-volume bars contribute less. Zero volume is treated as minimum value of 1 to avoid division issues.
4. **Cumulative vs Smoothed**: Cumulative mode creates a trending line that can grow unbounded; smoothed mode oscillates. Choose based on use case.
5. **Scale Dependency**: Raw III values scale with volume, making cross-security comparison difficult without normalization.
6. **Bar Corrections**: The `isNew=false` parameter allows updating the current bar. State rollback restores the previous state before recalculating.
## References
- Bostian, D. "Intraday Intensity Index." *Technical Analysis of Stocks & Commodities*.
- Arms, R. W. (1989). "The Arms Index (TRIN)." *Dow Jones-Irwin*.