Add Standardize class for Z-Score normalization and update project files

- Implemented the Standardize class for calculating Z-Score normalization over a specified lookback period.
- Updated NDepend badge SVG files to reflect new metrics.
- Modified NDepend project files to reference the updated solution file name.
- Removed outdated documentation files related to indicator proposals and channel documentation remediation.
- Updated workspace configuration to point to the new solution file.
This commit is contained in:
Miha Kralj
2026-02-07 12:47:13 -08:00
parent 58f0812584
commit 915d7a007b
59 changed files with 12387 additions and 698 deletions
+144
View File
@@ -0,0 +1,144 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class ImiIndicatorTests
{
[Fact]
public void ImiIndicator_Constructor_SetsDefaults()
{
var indicator = new ImiIndicator();
Assert.Equal(14, indicator.Period);
Assert.True(indicator.ShowColdValues);
Assert.Equal("Intraday Momentum Index", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void ImiIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new ImiIndicator { Period = 20 };
Assert.Equal(0, ImiIndicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void ImiIndicator_ShortName_IncludesParameters()
{
var indicator = new ImiIndicator { Period = 20 };
indicator.Initialize();
Assert.Contains("IMI", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("20", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void ImiIndicator_SourceCodeLink_IsValid()
{
var indicator = new ImiIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Imi.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void ImiIndicator_Initialize_CreatesInternalImi()
{
var indicator = new ImiIndicator { Period = 14 };
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist (single IMI line)
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void ImiIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new ImiIndicator { Period = 5 };
indicator.Initialize();
// Add historical data
var now = DateTime.UtcNow;
// Need enough bars for Period
for (int i = 0; i < 20; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
// 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 imi = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(imi));
Assert.InRange(imi, 0.0, 100.0);
}
[Fact]
public void ImiIndicator_AllUpBars_Returns100()
{
var indicator = new ImiIndicator { Period = 3 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 3; i++)
{
// Up bars: close > open
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 115, 99, 110);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
// All up bars should result in 100
Assert.Equal(100.0, indicator.LinesSeries[0].GetValue(0), 0.0001);
}
[Fact]
public void ImiIndicator_AllDownBars_Returns0()
{
var indicator = new ImiIndicator { Period = 3 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 3; i++)
{
// Down bars: close < open
indicator.HistoricalData.AddBar(now.AddMinutes(i), 110, 115, 99, 100);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
// All down bars should result in 0
Assert.Equal(0.0, indicator.LinesSeries[0].GetValue(0), 0.0001);
}
[Fact]
public void ImiIndicator_MixedBars_Returns50()
{
var indicator = new ImiIndicator { Period = 2 };
indicator.Initialize();
var now = DateTime.UtcNow;
// Up bar: gain = 10
indicator.HistoricalData.AddBar(now, 100, 115, 99, 110);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// Down bar: loss = 10
indicator.HistoricalData.AddBar(now.AddMinutes(1), 110, 115, 99, 100);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// Equal gains and losses should result in 50
Assert.Equal(50.0, indicator.LinesSeries[0].GetValue(0), 0.0001);
}
}
+51
View File
@@ -0,0 +1,51 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class ImiIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 1, 1000, 1, 0)]
public int Period { get; set; } = 14;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Imi _imi = null!;
private readonly LineSeries _imiSeries;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"IMI {Period}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/dynamics/imi/Imi.Quantower.cs";
public ImiIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "Intraday Momentum Index";
Description = "Technical indicator combining candlestick analysis with RSI-like calculation (Tushar Chande)";
_imiSeries = new LineSeries(name: "IMI", color: Color.Yellow, width: 2, style: LineStyle.Solid);
AddLineSeries(_imiSeries);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_imi = new Imi(Period);
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
TValue result = _imi.Update(this.GetInputBar(args), args.IsNewBar());
_imiSeries.SetValue(result.Value, _imi.IsHot, ShowColdValues);
}
}
+495
View File
@@ -0,0 +1,495 @@
using System;
using Xunit;
namespace QuanTAlib.Tests;
public class ImiTests
{
private const double Precision = 1e-10;
#region Constructor Tests
[Fact]
public void Constructor_DefaultPeriod_Is14()
{
var imi = new Imi();
Assert.Equal(14, imi.Period);
}
[Fact]
public void Constructor_CustomPeriod_IsSet()
{
var imi = new Imi(20);
Assert.Equal(20, imi.Period);
}
[Fact]
public void Constructor_Period1_IsValid()
{
var imi = new Imi(1);
Assert.Equal(1, imi.Period);
}
[Fact]
public void Constructor_ZeroPeriod_Throws()
{
Assert.Throws<ArgumentException>(() => new Imi(0));
}
[Fact]
public void Constructor_NegativePeriod_Throws()
{
Assert.Throws<ArgumentException>(() => new Imi(-1));
}
[Fact]
public void Name_ReflectsPeriod()
{
var imi = new Imi(10);
Assert.Equal("IMI(10)", imi.Name);
}
[Fact]
public void WarmupPeriod_EqualsToPeriod()
{
var imi = new Imi(14);
Assert.Equal(14, imi.WarmupPeriod);
}
#endregion
#region IsHot Tests
[Fact]
public void IsHot_BeforeWarmup_ReturnsFalse()
{
var imi = new Imi(5);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
for (int i = 0; i < 4; i++)
{
imi.Update(new TBar(baseTime + i * 60000, 100, 105, 95, 102, 1000));
}
Assert.False(imi.IsHot);
}
[Fact]
public void IsHot_AfterWarmup_ReturnsTrue()
{
var imi = new Imi(5);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
for (int i = 0; i < 5; i++)
{
imi.Update(new TBar(baseTime + i * 60000, 100, 105, 95, 102, 1000));
}
Assert.True(imi.IsHot);
}
#endregion
#region Basic Calculation Tests
[Fact]
public void Update_AllUpBars_Returns100()
{
var imi = new Imi(3);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// All bars have Close > Open (bullish candlesticks)
imi.Update(new TBar(baseTime, 100, 110, 99, 108, 1000)); // +8
imi.Update(new TBar(baseTime + 60000, 105, 112, 104, 111, 1000)); // +6
imi.Update(new TBar(baseTime + 120000, 108, 115, 107, 114, 1000)); // +6
Assert.Equal(100.0, imi.Last.Value, Precision);
}
[Fact]
public void Update_AllDownBars_Returns0()
{
var imi = new Imi(3);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// All bars have Close < Open (bearish candlesticks)
imi.Update(new TBar(baseTime, 108, 110, 99, 100, 1000)); // -8
imi.Update(new TBar(baseTime + 60000, 111, 112, 104, 105, 1000)); // -6
imi.Update(new TBar(baseTime + 120000, 114, 115, 107, 108, 1000)); // -6
Assert.Equal(0.0, imi.Last.Value, Precision);
}
[Fact]
public void Update_MixedBars_CorrectCalculation()
{
var imi = new Imi(4);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Up bar: gain = 5, loss = 0
imi.Update(new TBar(baseTime, 100, 110, 99, 105, 1000));
// Down bar: gain = 0, loss = 3
imi.Update(new TBar(baseTime + 60000, 105, 106, 100, 102, 1000));
// Up bar: gain = 4, loss = 0
imi.Update(new TBar(baseTime + 120000, 102, 108, 101, 106, 1000));
// Down bar: gain = 0, loss = 2
imi.Update(new TBar(baseTime + 180000, 106, 107, 103, 104, 1000));
// Gains = 5 + 4 = 9, Losses = 3 + 2 = 5
// IMI = 100 * 9 / (9 + 5) = 100 * 9 / 14 = 64.285714...
double expected = 100.0 * 9.0 / 14.0;
Assert.Equal(expected, imi.Last.Value, Precision);
}
[Fact]
public void Update_AllDoji_Returns50()
{
var imi = new Imi(3);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// All bars have Close == Open (doji candlesticks)
imi.Update(new TBar(baseTime, 100, 105, 95, 100, 1000));
imi.Update(new TBar(baseTime + 60000, 100, 108, 92, 100, 1000));
imi.Update(new TBar(baseTime + 120000, 100, 103, 97, 100, 1000));
// Sum of gains = 0, Sum of losses = 0, total = 0, returns 50 (neutral)
Assert.Equal(50.0, imi.Last.Value, Precision);
}
[Fact]
public void Update_EqualGainsAndLosses_Returns50()
{
var imi = new Imi(2);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Up bar: gain = 5
imi.Update(new TBar(baseTime, 100, 110, 99, 105, 1000));
// Down bar: loss = 5
imi.Update(new TBar(baseTime + 60000, 105, 106, 99, 100, 1000));
// Gains = 5, Losses = 5, IMI = 50
Assert.Equal(50.0, imi.Last.Value, Precision);
}
#endregion
#region Rolling Window Tests
[Fact]
public void Update_RollingWindow_DropsOldValues()
{
var imi = new Imi(3);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Fill with up bars
imi.Update(new TBar(baseTime, 100, 110, 99, 110, 1000)); // +10
imi.Update(new TBar(baseTime + 60000, 100, 110, 99, 110, 1000)); // +10
imi.Update(new TBar(baseTime + 120000, 100, 110, 99, 110, 1000)); // +10
Assert.Equal(100.0, imi.Last.Value, Precision);
// Add a down bar - oldest up bar should drop off
imi.Update(new TBar(baseTime + 180000, 110, 111, 99, 100, 1000)); // -10
// Now: gains = 10 + 10 = 20, losses = 10
// IMI = 100 * 20 / 30 = 66.666...
double expected = 100.0 * 20.0 / 30.0;
Assert.Equal(expected, imi.Last.Value, Precision);
}
#endregion
#region Bar Correction Tests
[Fact]
public void Update_BarCorrection_RestoresPreviousState()
{
var imi = new Imi(3);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Fill initial data
imi.Update(new TBar(baseTime, 100, 105, 95, 103, 1000));
imi.Update(new TBar(baseTime + 60000, 100, 105, 95, 104, 1000));
imi.Update(new TBar(baseTime + 120000, 100, 105, 95, 105, 1000));
// Add new bar (up)
imi.Update(new TBar(baseTime + 180000, 100, 107, 99, 106, 1000), isNew: true);
double valueAfterNew = imi.Last.Value;
// Correct the bar (now down)
imi.Update(new TBar(baseTime + 180000, 106, 107, 93, 94, 1000), isNew: false);
double valueAfterCorrection = imi.Last.Value;
// Values should differ based on the correction
Assert.NotEqual(valueAfterNew, valueAfterCorrection);
}
[Fact]
public void Update_MultipleCorrections_ProduceConsistentResults()
{
var imi = new Imi(3);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Fill buffer
for (int i = 0; i < 3; i++)
{
imi.Update(new TBar(baseTime + i * 60000, 100, 105, 95, 102, 1000));
}
// New bar
imi.Update(new TBar(baseTime + 3 * 60000, 100, 110, 99, 108, 1000), isNew: true);
double firstValue = imi.Last.Value;
// Correction 1
imi.Update(new TBar(baseTime + 3 * 60000, 100, 115, 99, 92, 1000), isNew: false);
// Correction 2 - same as first new bar
imi.Update(new TBar(baseTime + 3 * 60000, 100, 110, 99, 108, 1000), isNew: false);
double secondValue = imi.Last.Value;
Assert.Equal(firstValue, secondValue, Precision);
}
#endregion
#region NaN/Infinity Handling Tests
[Fact]
public void Update_NaNOpen_KeepsPreviousValue()
{
var imi = new Imi(3);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
imi.Update(new TBar(baseTime, 100, 105, 95, 103, 1000));
double validValue = imi.Last.Value;
imi.Update(new TBar(baseTime + 60000, double.NaN, 110, 99, 108, 1000));
Assert.Equal(validValue, imi.Last.Value);
}
[Fact]
public void Update_NaNClose_KeepsPreviousValue()
{
var imi = new Imi(3);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
imi.Update(new TBar(baseTime, 100, 105, 95, 103, 1000));
double validValue = imi.Last.Value;
imi.Update(new TBar(baseTime + 60000, 105, 110, 99, double.NaN, 1000));
Assert.Equal(validValue, imi.Last.Value);
}
[Fact]
public void Update_InfinityValues_KeepsPreviousValue()
{
var imi = new Imi(3);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
imi.Update(new TBar(baseTime, 100, 105, 95, 103, 1000));
double validValue = imi.Last.Value;
imi.Update(new TBar(baseTime + 60000, double.PositiveInfinity, 110, 99, 108, 1000));
Assert.Equal(validValue, imi.Last.Value);
}
#endregion
#region Reset Tests
[Fact]
public void Reset_ClearsState()
{
var imi = new Imi(3);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
for (int i = 0; i < 5; i++)
{
imi.Update(new TBar(baseTime + i * 60000, 100, 110, 99, 108, 1000));
}
Assert.True(imi.IsHot);
imi.Reset();
Assert.False(imi.IsHot);
Assert.Equal(0, imi.Last.Value);
}
[Fact]
public void Reset_AllowsFreshStart()
{
var imi = new Imi(3);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// All up bars
for (int i = 0; i < 3; i++)
{
imi.Update(new TBar(baseTime + i * 60000, 100, 110, 99, 108, 1000));
}
Assert.Equal(100.0, imi.Last.Value, Precision);
imi.Reset();
// All down bars
for (int i = 0; i < 3; i++)
{
imi.Update(new TBar(baseTime + i * 60000, 108, 110, 99, 100, 1000));
}
Assert.Equal(0.0, imi.Last.Value, Precision);
}
#endregion
#region Prime Tests
[Fact]
public void Prime_FillsBuffer()
{
var imi = new Imi(5);
var source = new TBarSeries();
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
for (int i = 0; i < 10; i++)
{
source.Add(new TBar(baseTime + i * 60000, 100, 110, 99, 108, 1000));
}
imi.Prime(source);
Assert.True(imi.IsHot);
Assert.Equal(100.0, imi.Last.Value, Precision);
}
#endregion
#region Batch Tests
[Fact]
public void Batch_ReturnsSeriesOfCorrectLength()
{
var source = new TBarSeries();
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
for (int i = 0; i < 20; i++)
{
source.Add(new TBar(baseTime + i * 60000, 100 + i, 110 + i, 90 + i, 105 + i, 1000));
}
var result = Imi.Batch(source);
Assert.Equal(20, result.Count);
}
[Fact]
public void Batch_EmptySource_ReturnsEmpty()
{
var source = new TBarSeries();
var result = Imi.Batch(source);
Assert.Empty(result);
}
[Fact]
public void Batch_CustomPeriod_AppliesCorrectly()
{
var source = new TBarSeries();
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
for (int i = 0; i < 20; i++)
{
source.Add(new TBar(baseTime + i * 60000, 100, 110, 99, 108, 1000));
}
var result = Imi.Batch(source, 5);
Assert.Equal(20, result.Count);
Assert.Equal(100.0, result[^1].Value, Precision);
}
[Fact]
public void Calculate_ReturnsBothResultsAndIndicator()
{
var source = new TBarSeries();
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
for (int i = 0; i < 20; i++)
{
source.Add(new TBar(baseTime + i * 60000, 100, 110, 99, 108, 1000));
}
var (results, indicator) = Imi.Calculate(source, 10);
Assert.Equal(20, results.Count);
Assert.True(indicator.IsHot);
Assert.Equal(10, indicator.Period);
}
#endregion
#region Event Publishing Tests
[Fact]
public void Update_PublishesEvent()
{
var imi = new Imi(3);
int eventCount = 0;
imi.Pub += (object? sender, in TValueEventArgs args) => eventCount++;
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
imi.Update(new TBar(baseTime, 100, 110, 99, 105, 1000));
Assert.Equal(1, eventCount);
}
[Fact]
public void Update_EventContainsCorrectValue()
{
var imi = new Imi(3);
TValue? receivedValue = null;
imi.Pub += (object? sender, in TValueEventArgs args) => receivedValue = args.Value;
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
imi.Update(new TBar(baseTime, 100, 110, 99, 110, 1000));
Assert.NotNull(receivedValue);
Assert.Equal(imi.Last.Value, receivedValue.Value.Value);
}
#endregion
#region GBM Random Data Test
[Fact]
public void Update_GbmData_ReturnsValueInRange()
{
var imi = new Imi(14);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var random = new Random(42);
double price = 100.0;
for (int i = 0; i < 100; i++)
{
double change = (random.NextDouble() - 0.5) * 4;
double open = price;
double high = Math.Max(open, open + Math.Abs(change) + random.NextDouble() * 2);
double low = Math.Min(open, open - Math.Abs(change) - random.NextDouble() * 2);
double close = open + change;
imi.Update(new TBar(baseTime + i * 60000, open, high, low, close, 1000));
price = close;
// IMI should always be in [0, 100]
Assert.InRange(imi.Last.Value, 0.0, 100.0);
}
}
#endregion
}
+332
View File
@@ -0,0 +1,332 @@
using System;
using Xunit;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for IMI (Intraday Momentum Index) implementation.
/// These tests validate the calculation against the published formula by Tushar Chande:
/// IMI = 100 × Sum(Gains) / (Sum(Gains) + Sum(Losses))
/// where Gain = Close - Open if Close > Open, else 0
/// and Loss = Open - Close if Close < Open, else 0
/// </summary>
public sealed class ImiValidationTests : IDisposable
{
private readonly ITestOutputHelper _output;
public ImiValidationTests(ITestOutputHelper output)
{
_output = output;
}
public void Dispose()
{
// Cleanup if needed
}
#region Manual Calculation Verification
[Fact]
public void ManualCalculation_SimpleUpBars()
{
// Given 3 up bars with known gains
var imi = new Imi(3);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Bar 1: Open=100, Close=105 → Gain=5
imi.Update(new TBar(baseTime, 100, 108, 98, 105, 1000));
// Bar 2: Open=105, Close=108 → Gain=3
imi.Update(new TBar(baseTime + 60000, 105, 110, 104, 108, 1000));
// Bar 3: Open=108, Close=110 → Gain=2
imi.Update(new TBar(baseTime + 120000, 108, 112, 107, 110, 1000));
// Total gains = 5 + 3 + 2 = 10
// Total losses = 0
// IMI = 100 × 10 / (10 + 0) = 100
Assert.Equal(100.0, imi.Last.Value, 1e-10);
_output.WriteLine($"Gains: 5 + 3 + 2 = 10");
_output.WriteLine($"Losses: 0");
_output.WriteLine($"IMI = 100 × 10 / 10 = {imi.Last.Value}");
}
[Fact]
public void ManualCalculation_SimpleDownBars()
{
// Given 3 down bars with known losses
var imi = new Imi(3);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Bar 1: Open=105, Close=100 → Loss=5
imi.Update(new TBar(baseTime, 105, 108, 98, 100, 1000));
// Bar 2: Open=100, Close=97 → Loss=3
imi.Update(new TBar(baseTime + 60000, 100, 102, 95, 97, 1000));
// Bar 3: Open=97, Close=95 → Loss=2
imi.Update(new TBar(baseTime + 120000, 97, 99, 93, 95, 1000));
// Total gains = 0
// Total losses = 5 + 3 + 2 = 10
// IMI = 100 × 0 / (0 + 10) = 0
Assert.Equal(0.0, imi.Last.Value, 1e-10);
_output.WriteLine($"Gains: 0");
_output.WriteLine($"Losses: 5 + 3 + 2 = 10");
_output.WriteLine($"IMI = 100 × 0 / 10 = {imi.Last.Value}");
}
[Fact]
public void ManualCalculation_MixedBars()
{
// Given a mix of up and down bars
var imi = new Imi(5);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Bar 1: Open=100, Close=106 → Gain=6
imi.Update(new TBar(baseTime, 100, 108, 98, 106, 1000));
// Bar 2: Open=106, Close=102 → Loss=4
imi.Update(new TBar(baseTime + 60000, 106, 108, 100, 102, 1000));
// Bar 3: Open=102, Close=105 → Gain=3
imi.Update(new TBar(baseTime + 120000, 102, 107, 101, 105, 1000));
// Bar 4: Open=105, Close=105 → Doji (Gain=0, Loss=0)
imi.Update(new TBar(baseTime + 180000, 105, 108, 102, 105, 1000));
// Bar 5: Open=105, Close=103 → Loss=2
imi.Update(new TBar(baseTime + 240000, 105, 107, 101, 103, 1000));
// Total gains = 6 + 3 = 9
// Total losses = 4 + 2 = 6
// IMI = 100 × 9 / (9 + 6) = 100 × 9 / 15 = 60
double expected = 100.0 * 9.0 / 15.0;
Assert.Equal(expected, imi.Last.Value, 1e-10);
_output.WriteLine($"Gains: 6 + 0 + 3 + 0 + 0 = 9");
_output.WriteLine($"Losses: 0 + 4 + 0 + 0 + 2 = 6");
_output.WriteLine($"IMI = 100 × 9 / 15 = {expected}");
_output.WriteLine($"Actual: {imi.Last.Value}");
}
#endregion
#region Rolling Window Validation
[Fact]
public void RollingWindow_DropsOldestValue()
{
var imi = new Imi(3);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Fill with 3 up bars (gains: 5, 5, 5)
imi.Update(new TBar(baseTime, 100, 108, 98, 105, 1000)); // +5
imi.Update(new TBar(baseTime + 60000, 100, 108, 98, 105, 1000)); // +5
imi.Update(new TBar(baseTime + 120000, 100, 108, 98, 105, 1000)); // +5
Assert.Equal(100.0, imi.Last.Value, 1e-10);
// Add a down bar (loss: 5) - oldest gain (5) drops off
imi.Update(new TBar(baseTime + 180000, 105, 108, 98, 100, 1000)); // -5
// Now: gains = 5 + 5 = 10, losses = 5
// IMI = 100 × 10 / 15 = 66.666...
double expected = 100.0 * 10.0 / 15.0;
Assert.Equal(expected, imi.Last.Value, 1e-10);
_output.WriteLine($"After 4th bar:");
_output.WriteLine($" Window: [+5, +5, -5]");
_output.WriteLine($" Gains: 5 + 5 = 10");
_output.WriteLine($" Losses: 5");
_output.WriteLine($" IMI = {expected}");
}
#endregion
#region Edge Case Validation
[Fact]
public void EdgeCase_AllDojiBars_Returns50()
{
// When all bars are doji (Open == Close), IMI should be 50 (neutral)
var imi = new Imi(5);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
for (int i = 0; i < 5; i++)
{
// Doji: Open == Close
imi.Update(new TBar(baseTime + i * 60000, 100, 105, 95, 100, 1000));
}
Assert.Equal(50.0, imi.Last.Value, 1e-10);
_output.WriteLine("All doji bars (O==C) → IMI = 50 (neutral)");
}
[Fact]
public void EdgeCase_VerySmallMovements()
{
var imi = new Imi(3);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Very small gains
imi.Update(new TBar(baseTime, 100.0, 100.1, 99.9, 100.0001, 1000));
imi.Update(new TBar(baseTime + 60000, 100.0, 100.1, 99.9, 100.0002, 1000));
imi.Update(new TBar(baseTime + 120000, 100.0, 100.1, 99.9, 100.0003, 1000));
// All are tiny up bars, should still be 100
Assert.Equal(100.0, imi.Last.Value, 1e-10);
_output.WriteLine($"Very small gains still → IMI = {imi.Last.Value}");
}
[Fact]
public void EdgeCase_Period1()
{
// With period 1, each bar is its own calculation
var imi = new Imi(1);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Up bar
imi.Update(new TBar(baseTime, 100, 110, 95, 108, 1000));
Assert.Equal(100.0, imi.Last.Value, 1e-10);
// Down bar
imi.Update(new TBar(baseTime + 60000, 108, 110, 95, 100, 1000));
Assert.Equal(0.0, imi.Last.Value, 1e-10);
// Doji
imi.Update(new TBar(baseTime + 120000, 100, 105, 95, 100, 1000));
Assert.Equal(50.0, imi.Last.Value, 1e-10);
_output.WriteLine("Period=1: Each bar → immediate IMI response");
}
#endregion
#region Investopedia Example Validation
[Fact]
public void InvestopediaFormula_MatchesDefinition()
{
// Validate against Investopedia formula:
// IMI = (Sum of Up Closes / (Sum of Up Closes + Sum of Down Closes)) × 100
// Where Up Close = Close - Open when Close > Open
var imi = new Imi(4);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Day 1: Close > Open (Up day: +3)
imi.Update(new TBar(baseTime, 50, 54, 49, 53, 1000));
// Day 2: Close < Open (Down day: -2)
imi.Update(new TBar(baseTime + 86400000, 53, 54, 50, 51, 1000));
// Day 3: Close > Open (Up day: +4)
imi.Update(new TBar(baseTime + 172800000, 51, 56, 50, 55, 1000));
// Day 4: Close > Open (Up day: +1)
imi.Update(new TBar(baseTime + 259200000, 55, 57, 54, 56, 1000));
// Sum of Up Closes = 3 + 4 + 1 = 8
// Sum of Down Closes = 2
// IMI = 100 × 8 / (8 + 2) = 80
double expected = 100.0 * 8.0 / 10.0;
Assert.Equal(expected, imi.Last.Value, 1e-10);
_output.WriteLine("Investopedia formula validation:");
_output.WriteLine($" Up gains: 3 + 4 + 1 = 8");
_output.WriteLine($" Down losses: 2");
_output.WriteLine($" IMI = 100 × 8 / 10 = {expected}");
}
#endregion
#region Comparison with RSI Concept
[Fact]
public void ImiVsRsiConcept_UsesIntradayNotInterday()
{
// IMI differs from RSI in that it uses Open-to-Close (intraday)
// rather than Close-to-Close (interday)
var imi = new Imi(3);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Bar 1: Open=100, Close=105 (up bar, +5)
// Bar 2: Open=110, Close=108 (down bar, -2)
// Note: This is up from prev close (105→108) but down intraday!
// Bar 3: Open=105, Close=110 (up bar, +5)
imi.Update(new TBar(baseTime, 100, 108, 98, 105, 1000));
imi.Update(new TBar(baseTime + 60000, 110, 112, 106, 108, 1000)); // Intraday down
imi.Update(new TBar(baseTime + 120000, 105, 112, 104, 110, 1000));
// Gains = 5 + 5 = 10
// Losses = 2
// IMI = 100 × 10 / 12 = 83.333...
double expected = 100.0 * 10.0 / 12.0;
Assert.Equal(expected, imi.Last.Value, 1e-10);
_output.WriteLine("IMI uses Open-to-Close (intraday), not Close-to-Close (interday)");
_output.WriteLine($"Bar 2: Opens at 110, closes at 108 → DOWN day for IMI");
_output.WriteLine($"IMI = {imi.Last.Value:F4}");
}
#endregion
#region Overbought/Oversold Levels
[Fact]
public void OverboughtLevel_Above70()
{
var imi = new Imi(5);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Create scenario with IMI > 70 (overbought)
// Need gains > 2.33 × losses for IMI > 70
// 4 up bars (+5 each), 1 down bar (-3)
// Gains = 20, Losses = 3
// IMI = 100 × 20/23 = 86.96
imi.Update(new TBar(baseTime, 100, 108, 98, 105, 1000)); // +5
imi.Update(new TBar(baseTime + 60000, 100, 108, 98, 105, 1000)); // +5
imi.Update(new TBar(baseTime + 120000, 100, 108, 98, 105, 1000)); // +5
imi.Update(new TBar(baseTime + 180000, 100, 108, 98, 105, 1000)); // +5
imi.Update(new TBar(baseTime + 240000, 100, 102, 95, 97, 1000)); // -3
Assert.True(imi.Last.Value > 70);
_output.WriteLine($"Overbought (>70): IMI = {imi.Last.Value:F2}");
}
[Fact]
public void OversoldLevel_Below30()
{
var imi = new Imi(5);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Create scenario with IMI < 30 (oversold)
// Need losses > 2.33 × gains for IMI < 30
// 4 down bars (-5 each), 1 up bar (+3)
// Gains = 3, Losses = 20
// IMI = 100 × 3/23 = 13.04
imi.Update(new TBar(baseTime, 105, 108, 98, 100, 1000)); // -5
imi.Update(new TBar(baseTime + 60000, 105, 108, 98, 100, 1000)); // -5
imi.Update(new TBar(baseTime + 120000, 105, 108, 98, 100, 1000)); // -5
imi.Update(new TBar(baseTime + 180000, 105, 108, 98, 100, 1000)); // -5
imi.Update(new TBar(baseTime + 240000, 100, 108, 98, 103, 1000)); // +3
Assert.True(imi.Last.Value < 30);
_output.WriteLine($"Oversold (<30): IMI = {imi.Last.Value:F2}");
}
#endregion
}
+251
View File
@@ -0,0 +1,251 @@
// IMI: Intraday Momentum Index
// Developed by Tushar Chande
// Combines candlestick analysis with RSI-like calculation
// Uses gain/loss based on intraday Open-Close relationship
using System.Runtime.CompilerServices;
namespace QuanTAlib;
/// <summary>
/// IMI: Intraday Momentum Index
/// </summary>
/// <remarks>
/// A technical indicator developed by Tushar Chande that combines candlestick analysis
/// with RSI-like overbought/oversold signals. Unlike RSI which uses close-to-close changes,
/// IMI uses the relationship between each bar's open and close prices.
///
/// Calculation:
/// <c>Gain = Close - Open (when Close > Open, otherwise 0)</c>
/// <c>Loss = Open - Close (when Close &lt; Open, otherwise 0)</c>
/// <c>IMI = 100 × Sum(Gains, n) / (Sum(Gains, n) + Sum(Losses, n))</c>
///
/// Key Levels:
/// - Above 70: Overbought condition
/// - Below 30: Oversold condition
/// - 50: Neutral (equal up and down momentum)
///
/// Sources:
/// - Investopedia: https://www.investopedia.com/terms/i/intraday-momentum-index-imi.asp
/// - CQG: https://help.cqg.com/cqgic/25/Documents/intradaymomentumindeximi.htm
/// </remarks>
[SkipLocalsInit]
public sealed class Imi : ITValuePublisher
{
private readonly int _period;
private readonly RingBuffer _gains;
private readonly RingBuffer _losses;
// Rolling sums for O(1) updates
private double _gainSum;
private double _lossSum;
// Bar correction state
private double _savedGainSum;
private double _savedLossSum;
/// <summary>
/// Display name for the indicator.
/// </summary>
public string Name { get; }
/// <summary>
/// Event publisher for value updates.
/// </summary>
public event TValuePublishedHandler? Pub;
/// <summary>
/// Current IMI value.
/// </summary>
public TValue Last { get; private set; }
/// <summary>
/// True if the indicator has enough data for a full period calculation.
/// </summary>
public bool IsHot => _gains.IsFull;
/// <summary>
/// The period parameter.
/// </summary>
public int Period => _period;
/// <summary>
/// The number of bars required for the indicator to warm up.
/// </summary>
public int WarmupPeriod { get; }
/// <summary>
/// Creates IMI indicator with specified period.
/// </summary>
/// <param name="period">Lookback period (must be >= 1)</param>
public Imi(int period = 14)
{
if (period < 1)
{
throw new ArgumentException("Period must be at least 1", nameof(period));
}
_period = period;
Name = $"IMI({period})";
WarmupPeriod = period;
_gains = new RingBuffer(period);
_losses = new RingBuffer(period);
_gainSum = 0.0;
_lossSum = 0.0;
_savedGainSum = 0.0;
_savedLossSum = 0.0;
}
/// <summary>
/// Resets the indicator state.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Reset()
{
_gains.Clear();
_losses.Clear();
_gainSum = 0.0;
_lossSum = 0.0;
_savedGainSum = 0.0;
_savedLossSum = 0.0;
Last = default;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void PubEvent(TValue value, bool isNew = true) =>
Pub?.Invoke(this, new TValueEventArgs { Value = value, IsNew = isNew });
/// <summary>
/// Updates the IMI indicator with a new bar.
/// </summary>
/// <param name="input">The price bar (Open, Close required)</param>
/// <param name="isNew">True for new bar, false for update of current bar</param>
/// <returns>The current IMI value</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TBar input, bool isNew = true)
{
double open = input.Open;
double close = input.Close;
// Handle NaN/Infinity inputs
if (!double.IsFinite(open) || !double.IsFinite(close))
{
PubEvent(Last, isNew);
return Last;
}
if (isNew)
{
// Save state for potential correction
_savedGainSum = _gainSum;
_savedLossSum = _lossSum;
}
else
{
// Restore state for correction
_gainSum = _savedGainSum;
_lossSum = _savedLossSum;
}
// Calculate gain and loss for this bar
double gain = 0.0;
double loss = 0.0;
if (close > open)
{
gain = close - open;
}
else if (close < open)
{
loss = open - close;
}
// When close == open, both gain and loss remain 0
// Update rolling sums: subtract old value if buffer is full
if (_gains.IsFull)
{
_gainSum -= _gains[0];
_lossSum -= _losses[0];
}
// Add new values to buffers
_gains.Add(gain, isNew);
_losses.Add(loss, isNew);
_gainSum += gain;
_lossSum += loss;
// Calculate IMI
double total = _gainSum + _lossSum;
double imi = total > 0 ? 100.0 * _gainSum / total : 50.0;
Last = new TValue(input.Time, imi);
PubEvent(Last, isNew);
return Last;
}
/// <summary>
/// Calculates IMI for the entire bar series.
/// </summary>
public TSeries Update(TBarSeries source)
{
if (source.Count == 0)
{
return new TSeries([], []);
}
int len = source.Count;
var tList = new List<long>(len);
var vList = new List<double>(len);
for (int i = 0; i < len; i++)
{
var bar = source[i];
Update(bar, isNew: true);
tList.Add(bar.Time);
vList.Add(Last.Value);
}
return new TSeries(tList, vList);
}
/// <summary>
/// Primes the indicator with historical bar data.
/// </summary>
public void Prime(TBarSeries source)
{
for (int i = 0; i < source.Count; i++)
{
Update(source[i], isNew: true);
}
}
/// <summary>
/// Calculates IMI for the entire bar series using default parameters.
/// </summary>
public static TSeries Batch(TBarSeries source)
{
var imi = new Imi();
return imi.Update(source);
}
/// <summary>
/// Calculates IMI for the entire bar series using custom period.
/// </summary>
public static TSeries Batch(TBarSeries source, int period)
{
var imi = new Imi(period);
return imi.Update(source);
}
/// <summary>
/// Calculates IMI and returns both results and the warm indicator.
/// </summary>
public static (TSeries Results, Imi Indicator) Calculate(TBarSeries source, int period = 14)
{
var imi = new Imi(period);
var results = imi.Update(source);
return (results, imi);
}
}