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
@@ -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,90 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
/// <summary>
/// Ichimoku Kinko Hyo (One Glance Equilibrium Chart) for Quantower.
/// Displays all five Ichimoku components: Tenkan-sen, Kijun-sen, Senkou Span A/B, and Chikou Span.
/// The cloud (Kumo) is formed between Senkou Span A and B.
/// </summary>
[SkipLocalsInit]
public sealed class IchimokuIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Tenkan Period", sortIndex: 1, 1, 500, 1, 0)]
public int TenkanPeriod { get; set; } = 9;
[InputParameter("Kijun Period", sortIndex: 2, 1, 500, 1, 0)]
public int KijunPeriod { get; set; } = 26;
[InputParameter("Senkou B Period", sortIndex: 3, 1, 500, 1, 0)]
public int SenkouBPeriod { get; set; } = 52;
[InputParameter("Displacement", sortIndex: 4, 1, 500, 1, 0)]
public int Displacement { get; set; } = 26;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Ichimoku _ichimoku = null!;
private readonly LineSeries _tenkanSeries;
private readonly LineSeries _kijunSeries;
private readonly LineSeries _senkouASeries;
private readonly LineSeries _senkouBSeries;
private readonly LineSeries _chikouSeries;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"ICHIMOKU({TenkanPeriod},{KijunPeriod},{SenkouBPeriod})";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/dynamics/ichimoku/Ichimoku.Quantower.cs";
public IchimokuIndicator()
{
OnBackGround = true;
SeparateWindow = false; // Overlay on price chart
Name = "Ichimoku Kinko Hyo";
Description = "Japanese equilibrium chart with Tenkan-sen, Kijun-sen, Senkou Spans, and Chikou Span";
// Standard Ichimoku colors following traditional conventions
_tenkanSeries = new LineSeries(name: "Tenkan-sen", color: Color.Blue, width: 1, style: LineStyle.Solid);
_kijunSeries = new LineSeries(name: "Kijun-sen", color: Color.Red, width: 2, style: LineStyle.Solid);
_senkouASeries = new LineSeries(name: "Senkou A", color: Color.Green, width: 1, style: LineStyle.Solid);
_senkouBSeries = new LineSeries(name: "Senkou B", color: Color.Salmon, width: 1, style: LineStyle.Solid);
_chikouSeries = new LineSeries(name: "Chikou", color: Color.Purple, width: 1, style: LineStyle.Solid);
AddLineSeries(_tenkanSeries);
AddLineSeries(_kijunSeries);
AddLineSeries(_senkouASeries);
AddLineSeries(_senkouBSeries);
AddLineSeries(_chikouSeries);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_ichimoku = new Ichimoku(TenkanPeriod, KijunPeriod, SenkouBPeriod, Displacement);
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
_ichimoku.Update(this.GetInputBar(args), args.IsNewBar());
// Tenkan-sen and Kijun-sen are plotted at current bar (no offset)
_tenkanSeries.SetValue(_ichimoku.Tenkan.Value, _ichimoku.IsHot, ShowColdValues);
_kijunSeries.SetValue(_ichimoku.Kijun.Value, _ichimoku.IsHot, ShowColdValues);
// Senkou Spans are plotted Displacement bars forward
// Note: In Quantower, LineSeries offset handling may need platform-specific implementation
// The values here represent current calculations; charting offset is handled by platform
_senkouASeries.SetValue(_ichimoku.SenkouA.Value, _ichimoku.IsHot, ShowColdValues);
_senkouBSeries.SetValue(_ichimoku.SenkouB.Value, _ichimoku.IsHot, ShowColdValues);
// Chikou Span is plotted Displacement bars backward
// Note: Similar to above, the offset is a display concern
_chikouSeries.SetValue(_ichimoku.Chikou.Value, _ichimoku.IsHot, ShowColdValues);
}
}
+560
View File
@@ -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,491 @@
using System;
using System.Collections.Generic;
using Xunit;
namespace QuanTAlib.Tests;
public class IchimokuValidationTests
{
private const double Precision = 1e-10;
#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
}
+478
View File
@@ -0,0 +1,478 @@
// ICHIMOKU: Ichimoku Kinko Hyo (One Glance Equilibrium Chart)
// A comprehensive trend-following indicator system with five components.
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// ICHIMOKU: Ichimoku Kinko Hyo (One Glance Equilibrium Chart)
/// </summary>
/// <remarks>
/// The Ichimoku Cloud is a multi-functional indicator developed by Japanese journalist
/// Goichi Hosoda, published in 1969. It provides support/resistance levels, trend direction,
/// momentum, and trading signals in a single view.
///
/// Five Components:
/// 1. Tenkan-sen (Conversion Line): (9-period high + 9-period low) / 2
/// - Short-term equilibrium, similar to fast MA
/// - Indicates short-term trend direction
///
/// 2. Kijun-sen (Base Line): (26-period high + 26-period low) / 2
/// - Medium-term equilibrium, similar to slow MA
/// - Key support/resistance level, used for stop-loss placement
///
/// 3. Senkou Span A (Leading Span A): (Tenkan-sen + Kijun-sen) / 2, plotted 26 periods ahead
/// - First boundary of the cloud (Kumo)
/// - Average of short and medium equilibrium
///
/// 4. Senkou Span B (Leading Span B): (52-period high + 52-period low) / 2, plotted 26 periods ahead
/// - Second boundary of the cloud (Kumo)
/// - Long-term equilibrium, usually flatter than Span A
///
/// 5. Chikou Span (Lagging Span): Current close plotted 26 periods behind
/// - Confirms trend by comparing current price to past
///
/// Cloud (Kumo): The area between Senkou Span A and B
/// - Provides key support/resistance zones
/// - Green cloud (A above B) = bullish
/// - Red cloud (B above A) = bearish
/// - Cloud thickness indicates strength of support/resistance
///
/// Default Parameters:
/// - Tenkan period: 9 (conversion line, short-term)
/// - Kijun period: 26 (base line, medium-term)
/// - Senkou B period: 52 (leading span B, long-term)
/// - Displacement: 26 (forward/backward shift for spans)
///
/// Sources:
/// Goichi Hosoda, "Ichimoku Kinko Hyo" (1969)
/// https://school.stockcharts.com/doku.php?id=technical_indicators:ichimoku_cloud
/// https://www.investopedia.com/terms/i/ichimoku-cloud.asp
/// </remarks>
/// <seealso href="ichimoku.pine">Reference Pine Script implementation</seealso>
[SkipLocalsInit]
public sealed class Ichimoku : ITValuePublisher
{
private readonly int _tenkanPeriod;
private readonly int _kijunPeriod;
private readonly int _senkouBPeriod;
private readonly int _displacement;
// Ring buffers for high/low tracking
private readonly double[] _highBuffer;
private readonly double[] _lowBuffer;
private readonly double[] _p_highBuffer;
private readonly double[] _p_lowBuffer;
// State tracking
[StructLayout(LayoutKind.Auto)]
private record struct State(
int Head,
int Count,
double LastValidHigh,
double LastValidLow,
double LastValidClose,
bool IsHot);
private State _state;
private State _p_state;
public string Name { get; }
public int WarmupPeriod { get; }
/// <summary>
/// Tenkan-sen (Conversion Line): Short-term equilibrium.
/// Calculated as (9-period high + 9-period low) / 2.
/// </summary>
public TValue Tenkan { get; private set; }
/// <summary>
/// Kijun-sen (Base Line): Medium-term equilibrium.
/// Calculated as (26-period high + 26-period low) / 2.
/// Key support/resistance level.
/// </summary>
public TValue Kijun { get; private set; }
/// <summary>
/// Senkou Span A (Leading Span A): First cloud boundary.
/// Calculated as (Tenkan + Kijun) / 2.
/// Note: This is the current value; displacement to future is applied in charting.
/// </summary>
public TValue SenkouA { get; private set; }
/// <summary>
/// Senkou Span B (Leading Span B): Second cloud boundary.
/// Calculated as (52-period high + 52-period low) / 2.
/// Note: This is the current value; displacement to future is applied in charting.
/// </summary>
public TValue SenkouB { get; private set; }
/// <summary>
/// Chikou Span (Lagging Span): Current close value.
/// Note: This value is plotted 26 periods behind in charting.
/// </summary>
public TValue Chikou { get; private set; }
/// <summary>
/// Primary output (Kijun-sen) for compatibility.
/// Kijun is often used as the main trend reference.
/// </summary>
public TValue Last => Kijun;
/// <summary>
/// True when all components have sufficient data.
/// </summary>
public bool IsHot => _state.IsHot;
/// <summary>
/// The displacement period for Senkou Spans and Chikou Span.
/// </summary>
public int Displacement => _displacement;
public event TValuePublishedHandler? Pub;
/// <summary>
/// Creates an Ichimoku Cloud indicator with default parameters.
/// Default: Tenkan=9, Kijun=26, Senkou B=52, Displacement=26.
/// </summary>
public Ichimoku() : this(9, 26, 52, 26)
{
}
/// <summary>
/// Creates an Ichimoku Cloud indicator with specified parameters.
/// </summary>
/// <param name="tenkanPeriod">Period for Tenkan-sen (Conversion Line), typically 9</param>
/// <param name="kijunPeriod">Period for Kijun-sen (Base Line), typically 26</param>
/// <param name="senkouBPeriod">Period for Senkou Span B (Leading Span B), typically 52</param>
/// <param name="displacement">Forward/backward shift for Senkou/Chikou spans, typically 26</param>
public Ichimoku(int tenkanPeriod, int kijunPeriod, int senkouBPeriod, int displacement)
{
if (tenkanPeriod <= 0)
{
throw new ArgumentOutOfRangeException(nameof(tenkanPeriod), "Tenkan period must be greater than 0");
}
if (kijunPeriod <= 0)
{
throw new ArgumentOutOfRangeException(nameof(kijunPeriod), "Kijun period must be greater than 0");
}
if (senkouBPeriod <= 0)
{
throw new ArgumentOutOfRangeException(nameof(senkouBPeriod), "Senkou B period must be greater than 0");
}
if (displacement <= 0)
{
throw new ArgumentOutOfRangeException(nameof(displacement), "Displacement must be greater than 0");
}
_tenkanPeriod = tenkanPeriod;
_kijunPeriod = kijunPeriod;
_senkouBPeriod = senkouBPeriod;
_displacement = displacement;
int maxPeriod = Math.Max(Math.Max(tenkanPeriod, kijunPeriod), senkouBPeriod);
_highBuffer = new double[maxPeriod];
_lowBuffer = new double[maxPeriod];
_p_highBuffer = new double[maxPeriod];
_p_lowBuffer = new double[maxPeriod];
WarmupPeriod = maxPeriod;
Name = $"Ichimoku({tenkanPeriod},{kijunPeriod},{senkouBPeriod},{displacement})";
Reset();
}
/// <summary>
/// Creates an Ichimoku Cloud indicator and primes it with a source series.
/// </summary>
public Ichimoku(TBarSeries source, int tenkanPeriod = 9, int kijunPeriod = 26,
int senkouBPeriod = 52, int displacement = 26)
: this(tenkanPeriod, kijunPeriod, senkouBPeriod, displacement)
{
Prime(source);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void PubEvent(TValue value, bool isNew = true) =>
Pub?.Invoke(this, new TValueEventArgs { Value = value, IsNew = isNew });
/// <summary>
/// Resets the indicator state.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Reset()
{
_state = new State(0, 0, double.NaN, double.NaN, double.NaN, false);
_p_state = _state;
Array.Fill(_highBuffer, double.NaN);
Array.Fill(_lowBuffer, double.NaN);
Array.Copy(_highBuffer, _p_highBuffer!, _highBuffer.Length);
Array.Copy(_lowBuffer, _p_lowBuffer!, _lowBuffer.Length);
Tenkan = default;
Kijun = default;
SenkouA = default;
SenkouB = default;
Chikou = default;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private (double high, double low, double close) GetValidHLC(TBar bar)
{
double high = bar.High;
double low = bar.Low;
double close = bar.Close;
if (double.IsFinite(high))
{
_state = _state with { LastValidHigh = high };
}
else
{
high = double.IsFinite(_state.LastValidHigh) ? _state.LastValidHigh : 0.0;
}
if (double.IsFinite(low))
{
_state = _state with { LastValidLow = low };
}
else
{
low = double.IsFinite(_state.LastValidLow) ? _state.LastValidLow : 0.0;
}
if (double.IsFinite(close))
{
_state = _state with { LastValidClose = close };
}
else
{
close = double.IsFinite(_state.LastValidClose) ? _state.LastValidClose : 0.0;
}
return (high, low, close);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private (double high, double low) GetDonchianMidpoint(int period)
{
int count = Math.Min(_state.Count, period);
if (count == 0)
{
return (double.NaN, double.NaN);
}
double highest = double.MinValue;
double lowest = double.MaxValue;
int head = _state.Head;
int bufLen = _highBuffer.Length;
for (int i = 0; i < count; i++)
{
int idx = (head - 1 - i + bufLen) % bufLen;
double h = _highBuffer[idx];
double l = _lowBuffer[idx];
if (double.IsFinite(h) && h > highest)
{
highest = h;
}
if (double.IsFinite(l) && l < lowest)
{
lowest = l;
}
}
return (highest, lowest);
}
/// <summary>
/// Updates the indicator with a new price bar.
/// </summary>
/// <param name="bar">Price bar with High, Low, Close</param>
/// <param name="isNew">True for new bar, false for bar update/correction</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TBar bar, bool isNew = true)
{
if (isNew)
{
_p_state = _state;
Array.Copy(_highBuffer, _p_highBuffer!, _highBuffer.Length);
Array.Copy(_lowBuffer, _p_lowBuffer!, _lowBuffer.Length);
}
else
{
_state = _p_state;
Array.Copy(_p_highBuffer!, _highBuffer, _highBuffer.Length);
Array.Copy(_p_lowBuffer!, _lowBuffer, _lowBuffer.Length);
}
var (high, low, close) = GetValidHLC(bar);
// Add to ring buffer
int head = _state.Head;
_highBuffer[head] = high;
_lowBuffer[head] = low;
int newHead = (head + 1) % _highBuffer.Length;
int newCount = Math.Min(_state.Count + 1, _highBuffer.Length);
_state = _state with { Head = newHead, Count = newCount };
// Calculate Tenkan-sen (9-period)
var (tenkanHigh, tenkanLow) = GetDonchianMidpoint(_tenkanPeriod);
double tenkanValue = (tenkanHigh + tenkanLow) / 2.0;
// Calculate Kijun-sen (26-period)
var (kijunHigh, kijunLow) = GetDonchianMidpoint(_kijunPeriod);
double kijunValue = (kijunHigh + kijunLow) / 2.0;
// Calculate Senkou Span A: (Tenkan + Kijun) / 2
double senkouAValue = (tenkanValue + kijunValue) / 2.0;
// Calculate Senkou Span B (52-period)
var (senkouBHigh, senkouBLow) = GetDonchianMidpoint(_senkouBPeriod);
double senkouBValue = (senkouBHigh + senkouBLow) / 2.0;
// Chikou Span is just the current close (plotted backwards in charting)
double chikouValue = close;
// Check if warmed up
if (!_state.IsHot && _state.Count >= WarmupPeriod)
{
_state = _state with { IsHot = true };
}
// Set outputs
Tenkan = new TValue(bar.Time, tenkanValue);
Kijun = new TValue(bar.Time, kijunValue);
SenkouA = new TValue(bar.Time, senkouAValue);
SenkouB = new TValue(bar.Time, senkouBValue);
Chikou = new TValue(bar.Time, chikouValue);
PubEvent(Last, isNew);
return Last;
}
/// <summary>
/// Updates the indicator with a single value (uses value as high, low, and close).
/// </summary>
/// <param name="input">Input value</param>
/// <param name="isNew">True for new bar, false for bar update/correction</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TValue input, bool isNew = true)
{
// Treat single value as H=L=C
var bar = new TBar(input.Time, input.Value, input.Value, input.Value, input.Value, 0);
return Update(bar, isNew);
}
/// <summary>
/// Processes a TBarSeries and returns tuple of all component series.
/// </summary>
public (TSeries Tenkan, TSeries Kijun, TSeries SenkouA, TSeries SenkouB, TSeries Chikou) Update(TBarSeries source)
{
if (source.Count == 0)
{
return (new TSeries([], []), new TSeries([], []), new TSeries([], []),
new TSeries([], []), new TSeries([], []));
}
int len = source.Count;
var tList = new List<long>(len);
var tenkanList = new List<double>(len);
var kijunList = new List<double>(len);
var senkouAList = new List<double>(len);
var senkouBList = new List<double>(len);
var chikouList = new List<double>(len);
for (int i = 0; i < len; i++)
{
var bar = source[i];
Update(bar, isNew: true);
tList.Add(bar.Time);
tenkanList.Add(Tenkan.Value);
kijunList.Add(Kijun.Value);
senkouAList.Add(SenkouA.Value);
senkouBList.Add(SenkouB.Value);
chikouList.Add(Chikou.Value);
}
return (
new TSeries(tList, tenkanList),
new TSeries(tList, kijunList),
new TSeries(tList, senkouAList),
new TSeries(tList, senkouBList),
new TSeries(tList, chikouList)
);
}
/// <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>
/// Primes the indicator with historical value data.
/// </summary>
public void Prime(TSeries source)
{
for (int i = 0; i < source.Count; i++)
{
Update(source[i], isNew: true);
}
}
/// <summary>
/// Calculates Ichimoku for the entire bar series using default parameters.
/// </summary>
public static (TSeries Tenkan, TSeries Kijun, TSeries SenkouA, TSeries SenkouB, TSeries Chikou) Batch(TBarSeries source)
{
var ichimoku = new Ichimoku();
return ichimoku.Update(source);
}
/// <summary>
/// Calculates Ichimoku for the entire bar series using custom parameters.
/// </summary>
public static (TSeries Tenkan, TSeries Kijun, TSeries SenkouA, TSeries SenkouB, TSeries Chikou) Batch(
TBarSeries source, int tenkanPeriod, int kijunPeriod, int senkouBPeriod, int displacement)
{
var ichimoku = new Ichimoku(tenkanPeriod, kijunPeriod, senkouBPeriod, displacement);
return ichimoku.Update(source);
}
/// <summary>
/// Calculates Ichimoku and returns both results and the warm indicator.
/// </summary>
public static ((TSeries Tenkan, TSeries Kijun, TSeries SenkouA, TSeries SenkouB, TSeries Chikou) Results, Ichimoku Indicator)
Calculate(TBarSeries source, int tenkanPeriod = 9, int kijunPeriod = 26, int senkouBPeriod = 52, int displacement = 26)
{
var ichimoku = new Ichimoku(tenkanPeriod, kijunPeriod, senkouBPeriod, displacement);
var results = ichimoku.Update(source);
return (results, ichimoku);
}
/// <summary>
/// Gets the Tenkan-sen period.
/// </summary>
public int TenkanPeriod => _tenkanPeriod;
/// <summary>
/// Gets the Kijun-sen period.
/// </summary>
public int KijunPeriod => _kijunPeriod;
/// <summary>
/// Gets the Senkou Span B period.
/// </summary>
public int SenkouBPeriod => _senkouBPeriod;
}