mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-25 13:58:04 +00:00
docs: remove C# Implementation Considerations sections, clean up temp scripts, reorganize test files
- Remove 'C# Implementation Considerations' sections from 34 indicator .md files - Delete 29 temp PowerShell scripts (_fix_mojibake.ps1, _hex_scan.ps1, etc.) - Move test files into tests/ subdirectories for consistent project structure - Add trader-focused bullet points to indicator documentation
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
using QuanTAlib;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class IchimokuIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void IchimokuIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new IchimokuIndicator();
|
||||
|
||||
Assert.Equal(9, indicator.TenkanPeriod);
|
||||
Assert.Equal(26, indicator.KijunPeriod);
|
||||
Assert.Equal(52, indicator.SenkouBPeriod);
|
||||
Assert.Equal(26, indicator.Displacement);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("Ichimoku Kinko Hyo", indicator.Name);
|
||||
Assert.False(indicator.SeparateWindow); // Overlay on price chart
|
||||
Assert.True(indicator.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IchimokuIndicator_MinHistoryDepths_EqualsZero()
|
||||
{
|
||||
var indicator = new IchimokuIndicator { TenkanPeriod = 10 };
|
||||
|
||||
Assert.Equal(0, IchimokuIndicator.MinHistoryDepths);
|
||||
IWatchlistIndicator watchlistIndicator = indicator;
|
||||
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IchimokuIndicator_ShortName_IncludesParameters()
|
||||
{
|
||||
var indicator = new IchimokuIndicator { TenkanPeriod = 9, KijunPeriod = 26, SenkouBPeriod = 52 };
|
||||
indicator.Initialize();
|
||||
|
||||
Assert.Contains("ICHIMOKU", indicator.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("9", indicator.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("26", indicator.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("52", indicator.ShortName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IchimokuIndicator_SourceCodeLink_IsValid()
|
||||
{
|
||||
var indicator = new IchimokuIndicator();
|
||||
|
||||
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
Assert.Contains("Ichimoku.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IchimokuIndicator_Initialize_CreatesInternalIchimoku()
|
||||
{
|
||||
var indicator = new IchimokuIndicator { TenkanPeriod = 9, KijunPeriod = 26, SenkouBPeriod = 52 };
|
||||
|
||||
// Initialize should not throw
|
||||
indicator.Initialize();
|
||||
|
||||
// After init, line series should exist (Tenkan, Kijun, SenkouA, SenkouB, Chikou)
|
||||
Assert.Equal(5, indicator.LinesSeries.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IchimokuIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new IchimokuIndicator { TenkanPeriod = 9, KijunPeriod = 26, SenkouBPeriod = 52 };
|
||||
indicator.Initialize();
|
||||
|
||||
// Add historical data - need enough bars for longest period (SenkouB = 52)
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 60; 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 values
|
||||
double tenkan = indicator.LinesSeries[0].GetValue(0);
|
||||
double kijun = indicator.LinesSeries[1].GetValue(0);
|
||||
double senkouA = indicator.LinesSeries[2].GetValue(0);
|
||||
double senkouB = indicator.LinesSeries[3].GetValue(0);
|
||||
double chikou = indicator.LinesSeries[4].GetValue(0);
|
||||
|
||||
Assert.True(double.IsFinite(tenkan));
|
||||
Assert.True(double.IsFinite(kijun));
|
||||
Assert.True(double.IsFinite(senkouA));
|
||||
Assert.True(double.IsFinite(senkouB));
|
||||
Assert.True(double.IsFinite(chikou));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IchimokuIndicator_FiveLineSeries_HaveCorrectNames()
|
||||
{
|
||||
var indicator = new IchimokuIndicator();
|
||||
indicator.Initialize();
|
||||
|
||||
Assert.Equal(5, indicator.LinesSeries.Count);
|
||||
Assert.Equal("Tenkan-sen", indicator.LinesSeries[0].Name);
|
||||
Assert.Equal("Kijun-sen", indicator.LinesSeries[1].Name);
|
||||
Assert.Equal("Senkou A", indicator.LinesSeries[2].Name);
|
||||
Assert.Equal("Senkou B", indicator.LinesSeries[3].Name);
|
||||
Assert.Equal("Chikou", indicator.LinesSeries[4].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IchimokuIndicator_CustomParameters_AppliesCorrectly()
|
||||
{
|
||||
var indicator = new IchimokuIndicator
|
||||
{
|
||||
TenkanPeriod = 10,
|
||||
KijunPeriod = 30,
|
||||
SenkouBPeriod = 60,
|
||||
Displacement = 30
|
||||
};
|
||||
indicator.Initialize();
|
||||
|
||||
Assert.Contains("10", indicator.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("30", indicator.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("60", indicator.ShortName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IchimokuIndicator_ConstantPrice_ProducesEqualLines()
|
||||
{
|
||||
var indicator = new IchimokuIndicator
|
||||
{
|
||||
TenkanPeriod = 3,
|
||||
KijunPeriod = 5,
|
||||
SenkouBPeriod = 10,
|
||||
Displacement = 5
|
||||
};
|
||||
indicator.Initialize();
|
||||
|
||||
// Add constant price bars
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 100, 100, 100);
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
}
|
||||
|
||||
// All Donchian midpoints should equal 100
|
||||
double tenkan = indicator.LinesSeries[0].GetValue(0);
|
||||
double kijun = indicator.LinesSeries[1].GetValue(0);
|
||||
double senkouA = indicator.LinesSeries[2].GetValue(0);
|
||||
double senkouB = indicator.LinesSeries[3].GetValue(0);
|
||||
|
||||
Assert.Equal(100.0, tenkan, precision: 10);
|
||||
Assert.Equal(100.0, kijun, precision: 10);
|
||||
Assert.Equal(100.0, senkouA, precision: 10);
|
||||
Assert.Equal(100.0, senkouB, precision: 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IchimokuIndicator_TrendingMarket_ComputesCorrectly()
|
||||
{
|
||||
var indicator = new IchimokuIndicator
|
||||
{
|
||||
TenkanPeriod = 3,
|
||||
KijunPeriod = 5,
|
||||
SenkouBPeriod = 10,
|
||||
Displacement = 5
|
||||
};
|
||||
indicator.Initialize();
|
||||
|
||||
// Add uptrending bars
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
double basePrice = 100 + i * 2;
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 5, basePrice - 5, basePrice);
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
}
|
||||
|
||||
// In uptrend, faster lines should be higher
|
||||
double tenkan = indicator.LinesSeries[0].GetValue(0);
|
||||
double kijun = indicator.LinesSeries[1].GetValue(0);
|
||||
|
||||
Assert.True(tenkan >= kijun);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IchimokuIndicator_Chikou_EqualsClose()
|
||||
{
|
||||
var indicator = new IchimokuIndicator
|
||||
{
|
||||
TenkanPeriod = 3,
|
||||
KijunPeriod = 5,
|
||||
SenkouBPeriod = 10,
|
||||
Displacement = 5
|
||||
};
|
||||
indicator.Initialize();
|
||||
|
||||
// Add bars with specific close price
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 110, 90, 105.5);
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
}
|
||||
|
||||
double chikou = indicator.LinesSeries[4].GetValue(0);
|
||||
Assert.Equal(105.5, chikou, precision: 10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
using System;
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class IchimokuTests
|
||||
{
|
||||
private const double Precision = 1e-10;
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DefaultParameters_SetsCorrectValues()
|
||||
{
|
||||
var ichimoku = new Ichimoku();
|
||||
Assert.Equal(9, ichimoku.TenkanPeriod);
|
||||
Assert.Equal(26, ichimoku.KijunPeriod);
|
||||
Assert.Equal(52, ichimoku.SenkouBPeriod);
|
||||
Assert.Equal(26, ichimoku.Displacement);
|
||||
Assert.Equal(52, ichimoku.WarmupPeriod); // Max of all periods
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CustomParameters_SetsCorrectValues()
|
||||
{
|
||||
var ichimoku = new Ichimoku(10, 30, 60, 30);
|
||||
Assert.Equal(10, ichimoku.TenkanPeriod);
|
||||
Assert.Equal(30, ichimoku.KijunPeriod);
|
||||
Assert.Equal(60, ichimoku.SenkouBPeriod);
|
||||
Assert.Equal(30, ichimoku.Displacement);
|
||||
Assert.Equal(60, ichimoku.WarmupPeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ZeroTenkanPeriod_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Ichimoku(0, 26, 52, 26));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativeKijunPeriod_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Ichimoku(9, -1, 52, 26));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ZeroSenkouBPeriod_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Ichimoku(9, 26, 0, 26));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ZeroDisplacement_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Ichimoku(9, 26, 52, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_FormatsCorrectly()
|
||||
{
|
||||
var ichimoku = new Ichimoku(9, 26, 52, 26);
|
||||
Assert.Equal("Ichimoku(9,26,52,26)", ichimoku.Name);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Warmup Tests
|
||||
|
||||
[Fact]
|
||||
public void IsHot_BeforeWarmup_ReturnsFalse()
|
||||
{
|
||||
var ichimoku = new Ichimoku(9, 26, 52, 26);
|
||||
var bar = new TBar(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), 100, 105, 95, 102, 1000);
|
||||
ichimoku.Update(bar);
|
||||
Assert.False(ichimoku.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_AfterWarmup_ReturnsTrue()
|
||||
{
|
||||
var ichimoku = new Ichimoku(9, 26, 52, 26);
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
for (int i = 0; i < 52; i++)
|
||||
{
|
||||
var bar = new TBar(baseTime + i * 60000, 100 + i, 105 + i, 95 + i, 102 + i, 1000);
|
||||
ichimoku.Update(bar);
|
||||
}
|
||||
|
||||
Assert.True(ichimoku.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarmupPeriod_BasedOnLongestPeriod()
|
||||
{
|
||||
var ichimoku1 = new Ichimoku(9, 26, 52, 26);
|
||||
Assert.Equal(52, ichimoku1.WarmupPeriod);
|
||||
|
||||
var ichimoku2 = new Ichimoku(100, 50, 30, 26);
|
||||
Assert.Equal(100, ichimoku2.WarmupPeriod);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Calculation Tests
|
||||
|
||||
[Fact]
|
||||
public void Tenkan_CalculatesDonchianMidpoint()
|
||||
{
|
||||
var ichimoku = new Ichimoku(3, 5, 10, 5);
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
// Add 3 bars with known high/low
|
||||
// Bar 1: H=110, L=90
|
||||
// Bar 2: H=115, L=85
|
||||
// Bar 3: H=105, L=95
|
||||
// 3-period high = 115, 3-period low = 85
|
||||
// Tenkan = (115 + 85) / 2 = 100
|
||||
|
||||
ichimoku.Update(new TBar(baseTime, 100, 110, 90, 100, 1000));
|
||||
ichimoku.Update(new TBar(baseTime + 60000, 100, 115, 85, 100, 1000));
|
||||
ichimoku.Update(new TBar(baseTime + 120000, 100, 105, 95, 100, 1000));
|
||||
|
||||
Assert.Equal(100.0, ichimoku.Tenkan.Value, Precision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kijun_CalculatesDonchianMidpoint()
|
||||
{
|
||||
var ichimoku = new Ichimoku(2, 3, 5, 3);
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
// Add 3 bars
|
||||
// Bar 1: H=110, L=90
|
||||
// Bar 2: H=120, L=80
|
||||
// Bar 3: H=115, L=85
|
||||
// 3-period high = 120, 3-period low = 80
|
||||
// Kijun = (120 + 80) / 2 = 100
|
||||
|
||||
ichimoku.Update(new TBar(baseTime, 100, 110, 90, 100, 1000));
|
||||
ichimoku.Update(new TBar(baseTime + 60000, 100, 120, 80, 100, 1000));
|
||||
ichimoku.Update(new TBar(baseTime + 120000, 100, 115, 85, 100, 1000));
|
||||
|
||||
Assert.Equal(100.0, ichimoku.Kijun.Value, Precision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SenkouA_AverageOfTenkanAndKijun()
|
||||
{
|
||||
var ichimoku = new Ichimoku(2, 3, 5, 3);
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
// Create scenario where Tenkan and Kijun have known values
|
||||
// Using same setup: 2-period for Tenkan, 3-period for Kijun
|
||||
|
||||
ichimoku.Update(new TBar(baseTime, 100, 110, 90, 100, 1000)); // T: (110+90)/2=100, K: (110+90)/2=100
|
||||
ichimoku.Update(new TBar(baseTime + 60000, 100, 120, 80, 100, 1000)); // T: (120+80)/2=100, K: (120+80)/2=100
|
||||
ichimoku.Update(new TBar(baseTime + 120000, 100, 100, 100, 100, 1000)); // T: (120+80)/2=100, K: (120+80)/2=100
|
||||
|
||||
// SenkouA = (Tenkan + Kijun) / 2 = (100 + 100) / 2 = 100
|
||||
Assert.Equal(100.0, ichimoku.SenkouA.Value, Precision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SenkouB_CalculatesLongestPeriodMidpoint()
|
||||
{
|
||||
var ichimoku = new Ichimoku(2, 3, 4, 3);
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
// Add 4 bars for full Senkou B calculation
|
||||
// Bar 1: H=100, L=90
|
||||
// Bar 2: H=110, L=85
|
||||
// Bar 3: H=105, L=88
|
||||
// Bar 4: H=108, L=92
|
||||
// 4-period high = 110, 4-period low = 85
|
||||
// SenkouB = (110 + 85) / 2 = 97.5
|
||||
|
||||
ichimoku.Update(new TBar(baseTime, 95, 100, 90, 95, 1000));
|
||||
ichimoku.Update(new TBar(baseTime + 60000, 100, 110, 85, 100, 1000));
|
||||
ichimoku.Update(new TBar(baseTime + 120000, 95, 105, 88, 95, 1000));
|
||||
ichimoku.Update(new TBar(baseTime + 180000, 100, 108, 92, 100, 1000));
|
||||
|
||||
Assert.Equal(97.5, ichimoku.SenkouB.Value, Precision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chikou_EqualsCurrentClose()
|
||||
{
|
||||
var ichimoku = new Ichimoku();
|
||||
long time = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
var bar = new TBar(time, 100, 105, 95, 102.5, 1000);
|
||||
ichimoku.Update(bar);
|
||||
|
||||
Assert.Equal(102.5, ichimoku.Chikou.Value, Precision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Last_ReturnsKijun()
|
||||
{
|
||||
var ichimoku = new Ichimoku();
|
||||
long time = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
var bar = new TBar(time, 100, 105, 95, 102, 1000);
|
||||
ichimoku.Update(bar);
|
||||
|
||||
Assert.Equal(ichimoku.Kijun.Value, ichimoku.Last.Value, Precision);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Single Value Update Tests
|
||||
|
||||
[Fact]
|
||||
public void Update_SingleValue_TreatsAsHLC()
|
||||
{
|
||||
var ichimoku = new Ichimoku(2, 3, 5, 3);
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
// When using single value, H=L=C=value
|
||||
ichimoku.Update(new TValue(baseTime, 100.0));
|
||||
ichimoku.Update(new TValue(baseTime + 60000, 100.0));
|
||||
ichimoku.Update(new TValue(baseTime + 120000, 100.0));
|
||||
|
||||
// All lines should equal 100 when all H=L=100
|
||||
Assert.Equal(100.0, ichimoku.Tenkan.Value, Precision);
|
||||
Assert.Equal(100.0, ichimoku.Kijun.Value, Precision);
|
||||
Assert.Equal(100.0, ichimoku.SenkouA.Value, Precision);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Bar Correction Tests
|
||||
|
||||
[Fact]
|
||||
public void Update_BarCorrection_RestoresPreviousState()
|
||||
{
|
||||
var ichimoku = new Ichimoku(3, 5, 10, 5);
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
// Add some initial bars
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
ichimoku.Update(new TBar(baseTime + i * 60000, 100, 105, 95, 100, 1000));
|
||||
}
|
||||
|
||||
// Capture state before update (use underscore to indicate intentionally unused)
|
||||
_ = ichimoku.Tenkan.Value;
|
||||
|
||||
// Update with new bar
|
||||
ichimoku.Update(new TBar(baseTime + 3 * 60000, 110, 120, 100, 115, 1000), isNew: true);
|
||||
double tenkanAfterNew = ichimoku.Tenkan.Value;
|
||||
|
||||
// Correct the bar (isNew=false) with different values
|
||||
ichimoku.Update(new TBar(baseTime + 3 * 60000, 90, 95, 85, 90, 1000), isNew: false);
|
||||
double tenkanAfterCorrection = ichimoku.Tenkan.Value;
|
||||
|
||||
// Values should differ based on the correction
|
||||
Assert.NotEqual(tenkanAfterNew, tenkanAfterCorrection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_SequentialCorrections_ProduceConsistentResults()
|
||||
{
|
||||
var ichimoku = new Ichimoku(3, 5, 10, 5);
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
// Fill buffer
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
ichimoku.Update(new TBar(baseTime + i * 60000, 100, 105, 95, 100, 1000));
|
||||
}
|
||||
|
||||
// First update
|
||||
ichimoku.Update(new TBar(baseTime + 5 * 60000, 105, 110, 100, 105, 1000), isNew: true);
|
||||
double firstTenkan = ichimoku.Tenkan.Value;
|
||||
|
||||
// Multiple corrections should converge
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
ichimoku.Update(new TBar(baseTime + 5 * 60000, 105, 110, 100, 105, 1000), isNew: false);
|
||||
}
|
||||
|
||||
Assert.Equal(firstTenkan, ichimoku.Tenkan.Value, Precision);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NaN/Invalid Input Tests
|
||||
|
||||
[Fact]
|
||||
public void Update_NaNHigh_UsesLastValidHigh()
|
||||
{
|
||||
var ichimoku = new Ichimoku(3, 5, 10, 5);
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
ichimoku.Update(new TBar(baseTime, 100, 110, 90, 100, 1000));
|
||||
ichimoku.Update(new TBar(baseTime + 60000, 100, 120, 80, 100, 1000));
|
||||
|
||||
// Now update with NaN high
|
||||
var barWithNaN = new TBar(baseTime + 120000, double.NaN, double.NaN, 85, 100, 1000);
|
||||
ichimoku.Update(barWithNaN);
|
||||
|
||||
// Should still produce valid output
|
||||
Assert.True(double.IsFinite(ichimoku.Tenkan.Value));
|
||||
Assert.True(double.IsFinite(ichimoku.Kijun.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_NaNLow_UsesLastValidLow()
|
||||
{
|
||||
var ichimoku = new Ichimoku(3, 5, 10, 5);
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
ichimoku.Update(new TBar(baseTime, 100, 110, 90, 100, 1000));
|
||||
ichimoku.Update(new TBar(baseTime + 60000, 100, 115, double.NaN, 100, 1000));
|
||||
|
||||
Assert.True(double.IsFinite(ichimoku.Tenkan.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_InfinityValues_FallbackToPrevious()
|
||||
{
|
||||
var ichimoku = new Ichimoku(3, 5, 10, 5);
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
ichimoku.Update(new TBar(baseTime, 100, 110, 90, 100, 1000));
|
||||
ichimoku.Update(new TBar(baseTime + 60000, double.PositiveInfinity, double.PositiveInfinity, double.NegativeInfinity, 100, 1000));
|
||||
|
||||
// Should handle gracefully
|
||||
Assert.True(double.IsFinite(ichimoku.Tenkan.Value));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reset Tests
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsAllState()
|
||||
{
|
||||
var ichimoku = new Ichimoku();
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
// Process some bars
|
||||
for (int i = 0; i < 60; i++)
|
||||
{
|
||||
ichimoku.Update(new TBar(baseTime + i * 60000, 100 + i, 105 + i, 95 + i, 100 + i, 1000));
|
||||
}
|
||||
|
||||
Assert.True(ichimoku.IsHot);
|
||||
|
||||
ichimoku.Reset();
|
||||
|
||||
Assert.False(ichimoku.IsHot);
|
||||
Assert.Equal(default, ichimoku.Tenkan);
|
||||
Assert.Equal(default, ichimoku.Kijun);
|
||||
Assert.Equal(default, ichimoku.SenkouA);
|
||||
Assert.Equal(default, ichimoku.SenkouB);
|
||||
Assert.Equal(default, ichimoku.Chikou);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_AllowsReuse()
|
||||
{
|
||||
var ichimoku = new Ichimoku(3, 5, 10, 5);
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
// First use
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
ichimoku.Update(new TBar(baseTime + i * 60000, 100, 110, 90, 100, 1000));
|
||||
}
|
||||
|
||||
double firstTenkan = ichimoku.Tenkan.Value;
|
||||
|
||||
// Reset and reuse
|
||||
ichimoku.Reset();
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
ichimoku.Update(new TBar(baseTime + i * 60000, 100, 110, 90, 100, 1000));
|
||||
}
|
||||
|
||||
Assert.Equal(firstTenkan, ichimoku.Tenkan.Value, Precision);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Batch Processing Tests
|
||||
|
||||
[Fact]
|
||||
public void Batch_ReturnsAllComponents()
|
||||
{
|
||||
var source = new TBarSeries();
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
for (int i = 0; i < 60; i++)
|
||||
{
|
||||
source.Add(new TBar(baseTime + i * 60000, 100, 110, 90, 100, 1000));
|
||||
}
|
||||
|
||||
var (tenkan, kijun, senkouA, senkouB, chikou) = Ichimoku.Batch(source);
|
||||
|
||||
Assert.Equal(60, tenkan.Count);
|
||||
Assert.Equal(60, kijun.Count);
|
||||
Assert.Equal(60, senkouA.Count);
|
||||
Assert.Equal(60, senkouB.Count);
|
||||
Assert.Equal(60, chikou.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_EmptySource_ReturnsEmptySeries()
|
||||
{
|
||||
var source = new TBarSeries();
|
||||
|
||||
var (tenkan, kijun, senkouA, senkouB, chikou) = Ichimoku.Batch(source);
|
||||
|
||||
Assert.Empty(tenkan);
|
||||
Assert.Empty(kijun);
|
||||
Assert.Empty(senkouA);
|
||||
Assert.Empty(senkouB);
|
||||
Assert.Empty(chikou);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_CustomParameters_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 + i, 110 + i, 90 + i, 100 + i, 1000));
|
||||
}
|
||||
|
||||
var (tenkan, _, _, _, _) = Ichimoku.Batch(source, 3, 5, 10, 5);
|
||||
|
||||
Assert.Equal(20, tenkan.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_ReturnsBothResultsAndIndicator()
|
||||
{
|
||||
var source = new TBarSeries();
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
for (int i = 0; i < 60; i++)
|
||||
{
|
||||
source.Add(new TBar(baseTime + i * 60000, 100, 110, 90, 100, 1000));
|
||||
}
|
||||
|
||||
var (results, indicator) = Ichimoku.Calculate(source);
|
||||
|
||||
Assert.Equal(60, results.Tenkan.Count);
|
||||
Assert.True(indicator.IsHot);
|
||||
Assert.Equal(52, indicator.WarmupPeriod);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Case Tests
|
||||
|
||||
[Fact]
|
||||
public void Update_ConstantPrice_AllLinesEqual()
|
||||
{
|
||||
var ichimoku = new Ichimoku(3, 5, 10, 5);
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
// Constant high=low=close=100
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
ichimoku.Update(new TBar(baseTime + i * 60000, 100, 100, 100, 100, 1000));
|
||||
}
|
||||
|
||||
Assert.Equal(100.0, ichimoku.Tenkan.Value, Precision);
|
||||
Assert.Equal(100.0, ichimoku.Kijun.Value, Precision);
|
||||
Assert.Equal(100.0, ichimoku.SenkouA.Value, Precision);
|
||||
Assert.Equal(100.0, ichimoku.SenkouB.Value, Precision);
|
||||
Assert.Equal(100.0, ichimoku.Chikou.Value, Precision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_SingleBar_ComputesCorrectly()
|
||||
{
|
||||
var ichimoku = new Ichimoku();
|
||||
long time = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
var bar = new TBar(time, 100, 110, 90, 100, 1000);
|
||||
ichimoku.Update(bar);
|
||||
|
||||
// With single bar: high=110, low=90
|
||||
// All midpoints = (110 + 90) / 2 = 100
|
||||
Assert.Equal(100.0, ichimoku.Tenkan.Value, Precision);
|
||||
Assert.Equal(100.0, ichimoku.Kijun.Value, Precision);
|
||||
Assert.Equal(100.0, ichimoku.SenkouA.Value, Precision);
|
||||
Assert.Equal(100.0, ichimoku.SenkouB.Value, Precision);
|
||||
Assert.Equal(100.0, ichimoku.Chikou.Value, Precision); // Close
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_TrendingMarket_CloudFormsCorrectly()
|
||||
{
|
||||
var ichimoku = new Ichimoku(3, 5, 10, 5);
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
// Uptrend: increasing highs and lows
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
double basePrice = 100 + i * 2;
|
||||
ichimoku.Update(new TBar(baseTime + i * 60000, basePrice, basePrice + 5, basePrice - 5, basePrice, 1000));
|
||||
}
|
||||
|
||||
// In uptrend, Tenkan should be above Kijun (faster vs slower)
|
||||
// And SenkouA should be above SenkouB (bullish cloud)
|
||||
Assert.True(ichimoku.Tenkan.Value >= ichimoku.Kijun.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllOutputs_HaveCorrectTimestamps()
|
||||
{
|
||||
var ichimoku = new Ichimoku();
|
||||
long time = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
var bar = new TBar(time, 100, 110, 90, 100, 1000);
|
||||
ichimoku.Update(bar);
|
||||
|
||||
Assert.Equal(time, ichimoku.Tenkan.Time);
|
||||
Assert.Equal(time, ichimoku.Kijun.Time);
|
||||
Assert.Equal(time, ichimoku.SenkouA.Time);
|
||||
Assert.Equal(time, ichimoku.SenkouB.Time);
|
||||
Assert.Equal(time, ichimoku.Chikou.Time);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Rolling Window Tests
|
||||
|
||||
[Fact]
|
||||
public void RollingWindow_OldValuesDroppedCorrectly()
|
||||
{
|
||||
var ichimoku = new Ichimoku(3, 3, 3, 3);
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
// Add first 3 bars: H ranging 100-120
|
||||
ichimoku.Update(new TBar(baseTime, 100, 100, 90, 100, 1000));
|
||||
ichimoku.Update(new TBar(baseTime + 60000, 110, 110, 100, 110, 1000));
|
||||
ichimoku.Update(new TBar(baseTime + 120000, 120, 120, 110, 120, 1000));
|
||||
|
||||
// Donchian midpoint = (120 + 90) / 2 = 105
|
||||
Assert.Equal(105.0, ichimoku.Tenkan.Value, Precision);
|
||||
|
||||
// Add 4th bar with H=130, L=120
|
||||
// Now window is bars 2,3,4: H=110,120,130 L=100,110,120
|
||||
// Donchian midpoint = (130 + 100) / 2 = 115
|
||||
ichimoku.Update(new TBar(baseTime + 180000, 130, 130, 120, 130, 1000));
|
||||
Assert.Equal(115.0, ichimoku.Tenkan.Value, Precision);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OoplesFinance.StockIndicators;
|
||||
using OoplesFinance.StockIndicators.Models;
|
||||
using Skender.Stock.Indicators;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class IchimokuValidationTests : IDisposable
|
||||
{
|
||||
private const double Precision = 1e-10;
|
||||
private readonly ValidationTestData _testData;
|
||||
private readonly ITestOutputHelper _output;
|
||||
private bool _disposed;
|
||||
|
||||
public IchimokuValidationTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
_testData = new ValidationTestData();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void Dispose(bool disposing)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_disposed = true;
|
||||
if (disposing)
|
||||
{
|
||||
_testData?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
#region Tenkan-sen Validation Tests
|
||||
|
||||
[Fact]
|
||||
public void Tenkan_ManualCalculation_MatchesDonchianMidpoint()
|
||||
{
|
||||
var ichimoku = new Ichimoku(3, 5, 10, 5);
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
// Bar sequence with known highs and lows:
|
||||
// Bar 1: H=110, L=90
|
||||
// Bar 2: H=115, L=85
|
||||
// Bar 3: H=108, L=92
|
||||
// 3-period high = max(110, 115, 108) = 115
|
||||
// 3-period low = min(90, 85, 92) = 85
|
||||
// Tenkan = (115 + 85) / 2 = 100
|
||||
|
||||
ichimoku.Update(new TBar(baseTime, 100, 110, 90, 100, 1000));
|
||||
ichimoku.Update(new TBar(baseTime + 60000, 100, 115, 85, 100, 1000));
|
||||
ichimoku.Update(new TBar(baseTime + 120000, 100, 108, 92, 100, 1000));
|
||||
|
||||
double expected = (115.0 + 85.0) / 2.0;
|
||||
Assert.Equal(expected, ichimoku.Tenkan.Value, Precision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tenkan_SlidingWindow_DropsOldValues()
|
||||
{
|
||||
var ichimoku = new Ichimoku(3, 5, 10, 5);
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
// Initial 3 bars: H range 100-120, L range 80-90
|
||||
ichimoku.Update(new TBar(baseTime, 90, 100, 80, 90, 1000)); // H=100, L=80
|
||||
ichimoku.Update(new TBar(baseTime + 60000, 100, 110, 85, 100, 1000)); // H=110, L=85
|
||||
ichimoku.Update(new TBar(baseTime + 120000, 110, 120, 90, 110, 1000)); // H=120, L=90
|
||||
|
||||
// Tenkan with bars 1-3: max(100,110,120)=120, min(80,85,90)=80
|
||||
// Tenkan = (120 + 80) / 2 = 100
|
||||
Assert.Equal(100.0, ichimoku.Tenkan.Value, Precision);
|
||||
|
||||
// Add 4th bar: H=105, L=95
|
||||
// Window now includes bars 2,3,4: H=110,120,105, L=85,90,95
|
||||
// max(110,120,105)=120, min(85,90,95)=85
|
||||
// Tenkan = (120 + 85) / 2 = 102.5
|
||||
ichimoku.Update(new TBar(baseTime + 180000, 100, 105, 95, 100, 1000));
|
||||
Assert.Equal(102.5, ichimoku.Tenkan.Value, Precision);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Kijun-sen Validation Tests
|
||||
|
||||
[Fact]
|
||||
public void Kijun_ManualCalculation_MatchesDonchianMidpoint()
|
||||
{
|
||||
var ichimoku = new Ichimoku(2, 4, 8, 4);
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
// 4 bars for Kijun calculation
|
||||
// Bar 1: H=105, L=95
|
||||
// Bar 2: H=110, L=90
|
||||
// Bar 3: H=115, L=85
|
||||
// Bar 4: H=108, L=92
|
||||
// 4-period high = max(105,110,115,108) = 115
|
||||
// 4-period low = min(95,90,85,92) = 85
|
||||
// Kijun = (115 + 85) / 2 = 100
|
||||
|
||||
ichimoku.Update(new TBar(baseTime, 100, 105, 95, 100, 1000));
|
||||
ichimoku.Update(new TBar(baseTime + 60000, 100, 110, 90, 100, 1000));
|
||||
ichimoku.Update(new TBar(baseTime + 120000, 100, 115, 85, 100, 1000));
|
||||
ichimoku.Update(new TBar(baseTime + 180000, 100, 108, 92, 100, 1000));
|
||||
|
||||
double expected = (115.0 + 85.0) / 2.0;
|
||||
Assert.Equal(expected, ichimoku.Kijun.Value, Precision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kijun_LongerPeriodThanTenkan_SmoothsMoreData()
|
||||
{
|
||||
var ichimoku = new Ichimoku(2, 4, 8, 4);
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
// Add 4 bars with increasing trend
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
double basePrice = 100 + (i * 5);
|
||||
ichimoku.Update(new TBar(baseTime + (i * 60000), basePrice, basePrice + 5, basePrice - 5, basePrice, 1000));
|
||||
}
|
||||
|
||||
// Tenkan (2-period) uses last 2 bars: bars 3,4
|
||||
// H range: 110+5, 115+5 = 115, 120 -> max=120
|
||||
// L range: 110-5, 115-5 = 105, 110 -> min=105
|
||||
// Tenkan = (120 + 105) / 2 = 112.5
|
||||
|
||||
// Kijun (4-period) uses all 4 bars
|
||||
// H range: 100+5, 105+5, 110+5, 115+5 = 105, 110, 115, 120 -> max=120
|
||||
// L range: 100-5, 105-5, 110-5, 115-5 = 95, 100, 105, 110 -> min=95
|
||||
// Kijun = (120 + 95) / 2 = 107.5
|
||||
|
||||
Assert.Equal(112.5, ichimoku.Tenkan.Value, Precision);
|
||||
Assert.Equal(107.5, ichimoku.Kijun.Value, Precision);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Senkou Span A Validation Tests
|
||||
|
||||
[Fact]
|
||||
public void SenkouA_ManualCalculation_AverageOfTenkanKijun()
|
||||
{
|
||||
var ichimoku = new Ichimoku(2, 3, 5, 3);
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
// Create scenario where we can calculate Tenkan and Kijun independently
|
||||
// Bar 1: H=100, L=80
|
||||
// Bar 2: H=120, L=70
|
||||
// Bar 3: H=110, L=90
|
||||
|
||||
ichimoku.Update(new TBar(baseTime, 90, 100, 80, 90, 1000));
|
||||
ichimoku.Update(new TBar(baseTime + 60000, 95, 120, 70, 95, 1000));
|
||||
ichimoku.Update(new TBar(baseTime + 120000, 100, 110, 90, 100, 1000));
|
||||
|
||||
// Tenkan (2-period): bars 2,3 -> H=120,110 max=120, L=70,90 min=70
|
||||
// Tenkan = (120 + 70) / 2 = 95
|
||||
|
||||
// Kijun (3-period): bars 1,2,3 -> H=100,120,110 max=120, L=80,70,90 min=70
|
||||
// Kijun = (120 + 70) / 2 = 95
|
||||
|
||||
// SenkouA = (Tenkan + Kijun) / 2 = (95 + 95) / 2 = 95
|
||||
|
||||
double expectedTenkan = (120.0 + 70.0) / 2.0;
|
||||
double expectedKijun = (120.0 + 70.0) / 2.0;
|
||||
double expectedSenkouA = (expectedTenkan + expectedKijun) / 2.0;
|
||||
|
||||
Assert.Equal(expectedTenkan, ichimoku.Tenkan.Value, Precision);
|
||||
Assert.Equal(expectedKijun, ichimoku.Kijun.Value, Precision);
|
||||
Assert.Equal(expectedSenkouA, ichimoku.SenkouA.Value, Precision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SenkouA_DifferentTenkanKijun_CorrectAverage()
|
||||
{
|
||||
var ichimoku = new Ichimoku(2, 4, 8, 4);
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
// Bars designed to give different Tenkan and Kijun
|
||||
ichimoku.Update(new TBar(baseTime, 100, 100, 60, 80, 1000)); // Very low bar
|
||||
ichimoku.Update(new TBar(baseTime + 60000, 100, 110, 90, 100, 1000));
|
||||
ichimoku.Update(new TBar(baseTime + 120000, 100, 120, 100, 110, 1000));
|
||||
ichimoku.Update(new TBar(baseTime + 180000, 110, 130, 110, 120, 1000));
|
||||
|
||||
// Tenkan (2-period): bars 3,4 -> H=120,130 max=130, L=100,110 min=100
|
||||
// Tenkan = (130 + 100) / 2 = 115
|
||||
|
||||
// Kijun (4-period): all bars -> H=100,110,120,130 max=130, L=60,90,100,110 min=60
|
||||
// Kijun = (130 + 60) / 2 = 95
|
||||
|
||||
// SenkouA = (115 + 95) / 2 = 105
|
||||
|
||||
double expectedTenkan = (130.0 + 100.0) / 2.0; // 115
|
||||
double expectedKijun = (130.0 + 60.0) / 2.0; // 95
|
||||
double expectedSenkouA = (expectedTenkan + expectedKijun) / 2.0; // 105
|
||||
|
||||
Assert.Equal(expectedTenkan, ichimoku.Tenkan.Value, Precision);
|
||||
Assert.Equal(expectedKijun, ichimoku.Kijun.Value, Precision);
|
||||
Assert.Equal(expectedSenkouA, ichimoku.SenkouA.Value, Precision);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Senkou Span B Validation Tests
|
||||
|
||||
[Fact]
|
||||
public void SenkouB_ManualCalculation_LongestPeriodMidpoint()
|
||||
{
|
||||
var ichimoku = new Ichimoku(2, 3, 5, 3);
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
// 5 bars for Senkou B calculation
|
||||
double[] highs = { 100, 110, 120, 115, 105 };
|
||||
double[] lows = { 90, 85, 80, 88, 92 };
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
ichimoku.Update(new TBar(baseTime + (i * 60000), (highs[i] + lows[i]) / 2, highs[i], lows[i], (highs[i] + lows[i]) / 2, 1000));
|
||||
}
|
||||
|
||||
// 5-period: max(100,110,120,115,105) = 120, min(90,85,80,88,92) = 80
|
||||
// SenkouB = (120 + 80) / 2 = 100
|
||||
|
||||
double expectedSenkouB = (120.0 + 80.0) / 2.0;
|
||||
Assert.Equal(expectedSenkouB, ichimoku.SenkouB.Value, Precision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SenkouB_LongestPeriod_IncorporatesAllData()
|
||||
{
|
||||
var ichimoku = new Ichimoku(3, 5, 10, 5);
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
// Add 10 bars with extreme at bar 1
|
||||
ichimoku.Update(new TBar(baseTime, 50, 200, 50, 125, 1000)); // Extreme high=200, low=50
|
||||
|
||||
for (int i = 1; i < 10; i++)
|
||||
{
|
||||
ichimoku.Update(new TBar(baseTime + (i * 60000), 100, 110, 90, 100, 1000));
|
||||
}
|
||||
|
||||
// 10-period includes the extreme bar
|
||||
// max(200,110,110,...) = 200, min(50,90,90,...) = 50
|
||||
// SenkouB = (200 + 50) / 2 = 125
|
||||
|
||||
Assert.Equal(125.0, ichimoku.SenkouB.Value, Precision);
|
||||
|
||||
// Add another bar to drop the extreme
|
||||
ichimoku.Update(new TBar(baseTime + (10 * 60000), 100, 110, 90, 100, 1000));
|
||||
|
||||
// Now 10-period window doesn't include extreme bar
|
||||
// max(110,110,...) = 110, min(90,90,...) = 90
|
||||
// SenkouB = (110 + 90) / 2 = 100
|
||||
|
||||
Assert.Equal(100.0, ichimoku.SenkouB.Value, Precision);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Chikou Span Validation Tests
|
||||
|
||||
[Fact]
|
||||
public void Chikou_EqualsCurrentClosePrice()
|
||||
{
|
||||
var ichimoku = new Ichimoku();
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
var testPrices = new double[] { 100.5, 102.3, 99.8, 105.0, 98.2 };
|
||||
|
||||
foreach (double closePrice in testPrices)
|
||||
{
|
||||
ichimoku.Update(new TBar(baseTime, 100, 110, 90, closePrice, 1000));
|
||||
Assert.Equal(closePrice, ichimoku.Chikou.Value, Precision);
|
||||
baseTime += 60000;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chikou_FollowsCloseExactly()
|
||||
{
|
||||
var ichimoku = new Ichimoku(3, 5, 10, 5);
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
double expectedClose = 100 + (i * 1.5);
|
||||
ichimoku.Update(new TBar(baseTime + (i * 60000), expectedClose, expectedClose + 5, expectedClose - 5, expectedClose, 1000));
|
||||
Assert.Equal(expectedClose, ichimoku.Chikou.Value, Precision);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cloud Formation Tests
|
||||
|
||||
[Fact]
|
||||
public void Cloud_BullishConfiguration_SenkouAAboveB()
|
||||
{
|
||||
var ichimoku = new Ichimoku(3, 5, 10, 5);
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
// Strong uptrend with recently higher prices
|
||||
// Short-term (Tenkan) and medium-term (Kijun) should be higher than long-term (SenkouB)
|
||||
// This creates bullish cloud where SenkouA > SenkouB
|
||||
|
||||
// Start with low prices
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
double price = 50 + i; // 50 to 59
|
||||
ichimoku.Update(new TBar(baseTime + (i * 60000), price, price + 5, price - 5, price, 1000));
|
||||
}
|
||||
|
||||
// Then jump to much higher prices - affects Tenkan and Kijun more than SenkouB
|
||||
for (int i = 10; i < 15; i++)
|
||||
{
|
||||
double price = 100 + ((i - 10) * 2);
|
||||
ichimoku.Update(new TBar(baseTime + (i * 60000), price, price + 5, price - 5, price, 1000));
|
||||
}
|
||||
|
||||
// In this scenario, SenkouA should be above SenkouB (bullish cloud)
|
||||
// because Tenkan and Kijun are averaging recent higher prices
|
||||
// while SenkouB still includes older lower prices
|
||||
Assert.True(ichimoku.SenkouA.Value >= ichimoku.SenkouB.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cloud_BearishConfiguration_SenkouBAboveA()
|
||||
{
|
||||
var ichimoku = new Ichimoku(3, 5, 10, 5);
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
// Downtrend scenario: start high, end low
|
||||
// SenkouB will remember old highs while Tenkan/Kijun fall
|
||||
|
||||
// Start with high prices
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
double price = 150 - i; // 150 down to 141
|
||||
ichimoku.Update(new TBar(baseTime + (i * 60000), price, price + 5, price - 5, price, 1000));
|
||||
}
|
||||
|
||||
// Then drop to much lower prices
|
||||
for (int i = 10; i < 15; i++)
|
||||
{
|
||||
double price = 100 - ((i - 10) * 3);
|
||||
ichimoku.Update(new TBar(baseTime + (i * 60000), price, price + 5, price - 5, price, 1000));
|
||||
}
|
||||
|
||||
// In downtrend, SenkouB (longer term) should be above SenkouA (bearish cloud)
|
||||
Assert.True(ichimoku.SenkouB.Value >= ichimoku.SenkouA.Value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Standard Ichimoku Parameters Tests
|
||||
|
||||
[Fact]
|
||||
public void StandardParameters_9_26_52_26_WorksCorrectly()
|
||||
{
|
||||
var ichimoku = new Ichimoku(); // Uses default 9, 26, 52, 26
|
||||
var barSeries = new TBarSeries();
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
// Generate 100 bars of simulated price data
|
||||
double price = 100;
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
// Random walk-ish price movement
|
||||
double change = (Math.Sin(i * 0.1) * 2) + Math.Cos(i * 0.05);
|
||||
price += change;
|
||||
barSeries.Add(new TBar(baseTime + (i * 60000), price, price + 2, price - 2, price, 1000));
|
||||
}
|
||||
|
||||
// Process all bars
|
||||
foreach (var bar in barSeries)
|
||||
{
|
||||
ichimoku.Update(bar);
|
||||
}
|
||||
|
||||
// After 52 bars, should be warmed up
|
||||
Assert.True(ichimoku.IsHot);
|
||||
|
||||
// All outputs should be finite
|
||||
Assert.True(double.IsFinite(ichimoku.Tenkan.Value));
|
||||
Assert.True(double.IsFinite(ichimoku.Kijun.Value));
|
||||
Assert.True(double.IsFinite(ichimoku.SenkouA.Value));
|
||||
Assert.True(double.IsFinite(ichimoku.SenkouB.Value));
|
||||
Assert.True(double.IsFinite(ichimoku.Chikou.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CryptoParameters_10_30_60_30_WorksCorrectly()
|
||||
{
|
||||
// Common crypto market settings (doubled because 24/7 markets)
|
||||
var ichimoku = new Ichimoku(10, 30, 60, 30);
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
// Process enough bars to warmup
|
||||
for (int i = 0; i < 70; i++)
|
||||
{
|
||||
double price = 40000 + (Math.Sin(i * 0.05) * 1000);
|
||||
ichimoku.Update(new TBar(baseTime + (i * 60000), price, price + 50, price - 50, price, 10));
|
||||
}
|
||||
|
||||
Assert.True(ichimoku.IsHot);
|
||||
Assert.Equal(60, ichimoku.WarmupPeriod); // Based on SenkouB period
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Batch Processing Validation Tests
|
||||
|
||||
[Fact]
|
||||
public void Batch_MatchesSequentialProcessing()
|
||||
{
|
||||
var barSeries = new TBarSeries();
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
for (int i = 0; i < 60; i++)
|
||||
{
|
||||
double price = 100 + i;
|
||||
barSeries.Add(new TBar(baseTime + (i * 60000), price, price + 5, price - 5, price, 1000));
|
||||
}
|
||||
|
||||
// Batch processing
|
||||
var (batchTenkan, batchKijun, batchSenkouA, batchSenkouB, batchChikou) = Ichimoku.Batch(barSeries);
|
||||
|
||||
// Sequential processing
|
||||
var sequential = new Ichimoku();
|
||||
var seqTenkan = new List<double>();
|
||||
var seqKijun = new List<double>();
|
||||
var seqSenkouA = new List<double>();
|
||||
var seqSenkouB = new List<double>();
|
||||
var seqChikou = new List<double>();
|
||||
|
||||
foreach (var bar in barSeries)
|
||||
{
|
||||
sequential.Update(bar);
|
||||
seqTenkan.Add(sequential.Tenkan.Value);
|
||||
seqKijun.Add(sequential.Kijun.Value);
|
||||
seqSenkouA.Add(sequential.SenkouA.Value);
|
||||
seqSenkouB.Add(sequential.SenkouB.Value);
|
||||
seqChikou.Add(sequential.Chikou.Value);
|
||||
}
|
||||
|
||||
// Compare results
|
||||
Assert.Equal(seqTenkan.Count, batchTenkan.Count);
|
||||
for (int i = 0; i < seqTenkan.Count; i++)
|
||||
{
|
||||
Assert.Equal(seqTenkan[i], batchTenkan[i].Value, Precision);
|
||||
Assert.Equal(seqKijun[i], batchKijun[i].Value, Precision);
|
||||
Assert.Equal(seqSenkouA[i], batchSenkouA[i].Value, Precision);
|
||||
Assert.Equal(seqSenkouB[i], batchSenkouB[i].Value, Precision);
|
||||
Assert.Equal(seqChikou[i], batchChikou[i].Value, Precision);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_ReturnsWarmIndicator()
|
||||
{
|
||||
var barSeries = new TBarSeries();
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
for (int i = 0; i < 60; i++)
|
||||
{
|
||||
double price = 100 + i;
|
||||
barSeries.Add(new TBar(baseTime + (i * 60000), price, price + 5, price - 5, price, 1000));
|
||||
}
|
||||
|
||||
var (results, indicator) = Ichimoku.Calculate(barSeries);
|
||||
|
||||
Assert.True(indicator.IsHot);
|
||||
Assert.Equal(52, indicator.WarmupPeriod);
|
||||
|
||||
// Last values in results should match indicator state
|
||||
Assert.Equal(indicator.Tenkan.Value, results.Tenkan.Last.Value, Precision);
|
||||
Assert.Equal(indicator.Kijun.Value, results.Kijun.Last.Value, Precision);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cross Validation Tests
|
||||
|
||||
[Fact]
|
||||
public void TenkanKijunCross_BullishSignal()
|
||||
{
|
||||
var ichimoku = new Ichimoku(3, 5, 10, 5);
|
||||
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
// Create scenario where Tenkan starts below Kijun, then crosses above
|
||||
|
||||
// Phase 1: Ranging market - Tenkan ≈ Kijun
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
ichimoku.Update(new TBar(baseTime + (i * 60000), 100, 105, 95, 100, 1000));
|
||||
}
|
||||
|
||||
// Capture initial state (using discards since we're testing the response to change)
|
||||
_ = ichimoku.Tenkan.Value;
|
||||
_ = ichimoku.Kijun.Value;
|
||||
|
||||
// Phase 2: Sharp upward move - Tenkan should rise faster
|
||||
for (int i = 5; i < 10; i++)
|
||||
{
|
||||
double price = 100 + ((i - 5) * 5);
|
||||
ichimoku.Update(new TBar(baseTime + (i * 60000), price, price + 3, price - 3, price, 1000));
|
||||
}
|
||||
|
||||
// Tenkan (short-term) should react faster to the uptrend
|
||||
// In uptrend, Tenkan >= Kijun
|
||||
Assert.True(ichimoku.Tenkan.Value >= ichimoku.Kijun.Value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Skender Cross-Validation Tests
|
||||
|
||||
[Fact]
|
||||
public void Validate_Skender_TenkanSen()
|
||||
{
|
||||
// Skender GetIchimoku returns IchimokuResult with TenkanSen (decimal?)
|
||||
// Both use Donchian midpoint: (highest-high + lowest-low) / 2 over tenkanPeriod
|
||||
var (qTenkan, _, _, _, _) = Ichimoku.Batch(_testData.Bars);
|
||||
var sResult = _testData.SkenderQuotes.GetIchimoku(9, 26, 52).ToList();
|
||||
|
||||
int count = Math.Min(qTenkan.Count, sResult.Count);
|
||||
int start = Math.Max(9, count - 100);
|
||||
int matched = 0;
|
||||
|
||||
for (int i = start; i < count; i++)
|
||||
{
|
||||
double qValue = qTenkan[i].Value;
|
||||
decimal? sValue = sResult[i].TenkanSen;
|
||||
if (!sValue.HasValue || !double.IsFinite(qValue))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
double diff = Math.Abs(qValue - (double)sValue.Value);
|
||||
Assert.True(diff <= ValidationHelper.SkenderTolerance,
|
||||
$"Tenkan mismatch at [{i}]: QuanTAlib={qValue:G17}, Skender={(double)sValue.Value:G17}, diff={diff:E3}");
|
||||
matched++;
|
||||
}
|
||||
|
||||
Assert.True(matched > 50, $"Only matched {matched} Tenkan values");
|
||||
_output.WriteLine($"Ichimoku Tenkan validated against Skender ({matched} values matched)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Skender_KijunSen()
|
||||
{
|
||||
var (_, qKijun, _, _, _) = Ichimoku.Batch(_testData.Bars);
|
||||
var sResult = _testData.SkenderQuotes.GetIchimoku(9, 26, 52).ToList();
|
||||
|
||||
int count = Math.Min(qKijun.Count, sResult.Count);
|
||||
int start = Math.Max(26, count - 100);
|
||||
int matched = 0;
|
||||
|
||||
for (int i = start; i < count; i++)
|
||||
{
|
||||
double qValue = qKijun[i].Value;
|
||||
decimal? sValue = sResult[i].KijunSen;
|
||||
if (!sValue.HasValue || !double.IsFinite(qValue))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
double diff = Math.Abs(qValue - (double)sValue.Value);
|
||||
Assert.True(diff <= ValidationHelper.SkenderTolerance,
|
||||
$"Kijun mismatch at [{i}]: QuanTAlib={qValue:G17}, Skender={(double)sValue.Value:G17}, diff={diff:E3}");
|
||||
matched++;
|
||||
}
|
||||
|
||||
Assert.True(matched > 50, $"Only matched {matched} Kijun values");
|
||||
_output.WriteLine($"Ichimoku Kijun validated against Skender ({matched} values matched)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Skender_SenkouSpanB()
|
||||
{
|
||||
// SenkouSpanB is the Donchian midpoint over the longest period (52)
|
||||
// Note: Skender shifts SenkouB forward by displacement periods in its output array,
|
||||
// so sResult[i].SenkouSpanB at index i is the value computed for bar (i - displacement).
|
||||
// QuanTAlib does NOT apply displacement in its batch output.
|
||||
// Therefore: QuanTAlib SenkouB[i] should match Skender SenkouSpanB[i + displacement].
|
||||
var (_, _, _, qSenkouB, _) = Ichimoku.Batch(_testData.Bars);
|
||||
var sResult = _testData.SkenderQuotes.GetIchimoku(9, 26, 52).ToList();
|
||||
|
||||
int displacement = 26;
|
||||
int count = Math.Min(qSenkouB.Count, sResult.Count - displacement);
|
||||
int start = Math.Max(52, count - 100);
|
||||
int matched = 0;
|
||||
|
||||
for (int i = start; i < count; i++)
|
||||
{
|
||||
double qValue = qSenkouB[i].Value;
|
||||
int sIdx = i + displacement;
|
||||
if (sIdx >= sResult.Count)
|
||||
{
|
||||
break;
|
||||
}
|
||||
decimal? sValue = sResult[sIdx].SenkouSpanB;
|
||||
if (!sValue.HasValue || !double.IsFinite(qValue))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
double diff = Math.Abs(qValue - (double)sValue.Value);
|
||||
Assert.True(diff <= ValidationHelper.SkenderTolerance,
|
||||
$"SenkouB mismatch at q[{i}] vs s[{sIdx}]: QuanTAlib={qValue:G17}, Skender={(double)sValue.Value:G17}, diff={diff:E3}");
|
||||
matched++;
|
||||
}
|
||||
|
||||
Assert.True(matched > 30, $"Only matched {matched} SenkouB values");
|
||||
_output.WriteLine($"Ichimoku SenkouB validated against Skender ({matched} values, offset +{displacement})");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Skender_ChikouSpan()
|
||||
{
|
||||
// Chikou Span = current close price (plotted backward by displacement)
|
||||
// Both should agree that Chikou = Close at each bar
|
||||
var (_, _, _, _, qChikou) = Ichimoku.Batch(_testData.Bars);
|
||||
var sResult = _testData.SkenderQuotes.GetIchimoku(9, 26, 52).ToList();
|
||||
|
||||
int displacement = 26;
|
||||
int count = Math.Min(qChikou.Count, sResult.Count);
|
||||
int matched = 0;
|
||||
|
||||
// Skender stores ChikouSpan at index (i - displacement), i.e. sResult[i].ChikouSpan
|
||||
// is the close of bar (i + displacement). QuanTAlib Chikou[i] = Close[i].
|
||||
// So QuanTAlib Chikou[i] == Skender ChikouSpan[i - displacement] when i >= displacement.
|
||||
for (int i = displacement; i < count; i++)
|
||||
{
|
||||
double qValue = qChikou[i].Value;
|
||||
int sIdx = i - displacement;
|
||||
decimal? sValue = sResult[sIdx].ChikouSpan;
|
||||
if (!sValue.HasValue || !double.IsFinite(qValue))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
double diff = Math.Abs(qValue - (double)sValue.Value);
|
||||
Assert.True(diff <= ValidationHelper.SkenderTolerance,
|
||||
$"Chikou mismatch at q[{i}] vs s[{sIdx}]: QuanTAlib={qValue:G17}, Skender={(double)sValue.Value:G17}, diff={diff:E3}");
|
||||
matched++;
|
||||
}
|
||||
|
||||
Assert.True(matched > 50, $"Only matched {matched} Chikou values");
|
||||
_output.WriteLine($"Ichimoku Chikou validated against Skender ({matched} values matched)");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Ooples Cross-Validation
|
||||
|
||||
[Fact]
|
||||
public void Ichimoku_MatchesOoples_Structural()
|
||||
{
|
||||
// CalculateIchimokuCloud — structural test; outputs stored in OutputValues (Tenkan/Kijun/etc.)
|
||||
var ooplesData = _testData.SkenderQuotes
|
||||
.Select(q => new TickerData { Date = q.Date, Open = (double)q.Open, High = (double)q.High, Low = (double)q.Low, Close = (double)q.Close, Volume = (double)q.Volume })
|
||||
.ToList();
|
||||
|
||||
var result = new StockData(ooplesData).CalculateIchimokuCloud();
|
||||
// Ooples multi-output indicators store results in OutputValues, not CustomValuesList
|
||||
var allValues = result.OutputValues.Values.SelectMany(v => v).ToList();
|
||||
|
||||
int finiteCount = allValues.Count(v => double.IsFinite(v));
|
||||
Assert.True(finiteCount > 100, $"Expected >100 finite Ooples Ichimoku values, got {finiteCount}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
[Fact]
|
||||
public void Ichimoku_Correction_Recomputes()
|
||||
{
|
||||
var ind = new Ichimoku();
|
||||
var t0 = new DateTime(946_684_800_000_000_0L, DateTimeKind.Utc);
|
||||
|
||||
// Build state well past warmup
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
double p = 100.0 + (10.0 * Math.Sin(2.0 * Math.PI * i / 20.0));
|
||||
ind.Update(new TBar(t0.AddMinutes(i), p, p + 2, p - 2, p, 1000), isNew: true);
|
||||
}
|
||||
|
||||
// Anchor bar
|
||||
var anchorTime = t0.AddMinutes(100);
|
||||
const double anchorClose = 105.5;
|
||||
ind.Update(new TBar(anchorTime, anchorClose, anchorClose + 2, anchorClose - 2, anchorClose, 1000), isNew: true);
|
||||
double anchorTenkan = ind.Tenkan.Value;
|
||||
|
||||
// Correction with a dramatically different price — Tenkan must change
|
||||
ind.Update(new TBar(anchorTime, anchorClose * 10, (anchorClose + 2) * 10, (anchorClose - 2) * 10, anchorClose * 10, 1000), isNew: false);
|
||||
Assert.NotEqual(anchorTenkan, ind.Tenkan.Value);
|
||||
|
||||
// Correction back to original price — must exactly restore original Tenkan
|
||||
ind.Update(new TBar(anchorTime, anchorClose, anchorClose + 2, anchorClose - 2, anchorClose, 1000), isNew: false);
|
||||
Assert.Equal(anchorTenkan, ind.Tenkan.Value, 1e-9);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user