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:
Miha Kralj
2026-03-12 12:34:16 -07:00
parent 8937b0c0fa
commit 060649192f
1149 changed files with 1780 additions and 3316 deletions
+173
View File
@@ -0,0 +1,173 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public class HaIndicatorTests
{
[Fact]
public void HaIndicator_Constructor_SetsDefaults()
{
var indicator = new HaIndicator();
Assert.True(indicator.ShowColdValues);
Assert.Equal("HA - Heikin-Ashi", indicator.Name);
Assert.False(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void HaIndicator_ShortName_IsHa()
{
var indicator = new HaIndicator();
Assert.Equal("HA", indicator.ShortName);
}
[Fact]
public void HaIndicator_MinHistoryDepths_EqualsOne()
{
var indicator = new HaIndicator();
Assert.Equal(1, HaIndicator.MinHistoryDepths);
Assert.Equal(1, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void HaIndicator_Initialize_CreatesFourLineSeries()
{
var indicator = new HaIndicator();
indicator.Initialize();
Assert.Equal(4, indicator.LinesSeries.Count);
}
[Fact]
public void HaIndicator_ProcessUpdate_HistoricalBar_ComputesValues()
{
var indicator = new HaIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
double basePrice = 100 + i;
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 5, basePrice - 5, basePrice + 1, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
// All 4 series should have finite values
for (int s = 0; s < 4; s++)
{
double val = indicator.LinesSeries[s].GetValue(0);
Assert.True(double.IsFinite(val), $"LineSeries[{s}] should be finite");
}
}
[Fact]
public void HaIndicator_ProcessUpdate_NewBar_ComputesValues()
{
var indicator = new HaIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 105 + i, 95 + i, 102 + i, 1000);
}
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicator.HistoricalData.AddBar(now.AddMinutes(10), 110, 115, 105, 112, 1500);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(2, indicator.LinesSeries[0].Count);
Assert.Equal(2, indicator.LinesSeries[1].Count);
Assert.Equal(2, indicator.LinesSeries[2].Count);
Assert.Equal(2, indicator.LinesSeries[3].Count);
}
[Fact]
public void HaIndicator_ShowColdValues_CanBeToggled()
{
var indicator = new HaIndicator();
Assert.True(indicator.ShowColdValues);
indicator.ShowColdValues = false;
Assert.False(indicator.ShowColdValues);
indicator.ShowColdValues = true;
Assert.True(indicator.ShowColdValues);
}
[Fact]
public void HaIndicator_SourceCodeLink_IsValid()
{
var indicator = new HaIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Ha.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void HaIndicator_ComputesCorrectValues()
{
var indicator = new HaIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
// First bar: O=100, H=110, L=90, C=105
// HA_Close = (100+110+90+105)/4 = 101.25
// HA_Open = (100+105)/2 = 102.5 (seed)
// HA_High = max(110, 102.5, 101.25) = 110
// HA_Low = min(90, 102.5, 101.25) = 90
indicator.HistoricalData.AddBar(now, 100, 110, 90, 105, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
double haOpen = indicator.LinesSeries[0].GetValue(0);
double haHigh = indicator.LinesSeries[1].GetValue(0);
double haLow = indicator.LinesSeries[2].GetValue(0);
double haClose = indicator.LinesSeries[3].GetValue(0);
Assert.Equal(102.5, haOpen, 10);
Assert.Equal(110.0, haHigh, 10);
Assert.Equal(90.0, haLow, 10);
Assert.Equal(101.25, haClose, 10);
}
[Fact]
public void HaIndicator_IsHotImmediately()
{
var indicator = new HaIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// All 4 series should have finite values (IsHot after first bar)
for (int s = 0; s < 4; s++)
{
double val = indicator.LinesSeries[s].GetValue(0);
Assert.True(double.IsFinite(val), $"LineSeries[{s}] should be finite after one bar");
}
}
[Fact]
public void HaIndicator_HighAlwaysAboveOrEqualLow()
{
var indicator = new HaIndicator();
indicator.Initialize();
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 + 1, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double haHigh = indicator.LinesSeries[1].GetValue(0);
double haLow = indicator.LinesSeries[2].GetValue(0);
Assert.True(haHigh >= haLow, "HA High must be >= HA Low");
}
}
+457
View File
@@ -0,0 +1,457 @@
// Ha Unit Tests
using Xunit;
namespace QuanTAlib.Tests;
public class HaTests
{
private readonly GBM _gbm;
private const double Tolerance = 1e-10;
public HaTests()
{
_gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42);
}
private TBarSeries GenerateBars(int count)
{
_gbm.Reset(DateTime.UtcNow.Ticks);
return _gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
}
#region Constructor Tests
[Fact]
public void Constructor_DefaultParameters_SetsCorrectValues()
{
var indicator = new Ha();
Assert.Equal("Ha", indicator.Name);
Assert.Equal(1, indicator.WarmupPeriod);
}
[Fact]
public void Constructor_WithSource_SubscribesToEvents()
{
var source = new TSeries();
var indicator = new Ha(source);
source.Add(new TValue(DateTime.UtcNow, 100.0));
Assert.NotEqual(default, indicator.Last);
}
#endregion
#region Basic Calculation Tests
[Fact]
public void Update_FirstBar_HaCloseIsOHLC4()
{
var indicator = new Ha();
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000);
var result = indicator.UpdateBar(bar);
// HA Close = (100 + 110 + 90 + 105) / 4 = 101.25
Assert.Equal(101.25, result.Close, Tolerance);
}
[Fact]
public void Update_FirstBar_HaOpenIsMidpointOC()
{
var indicator = new Ha();
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000);
var result = indicator.UpdateBar(bar);
// HA Open on first bar = (O + C) / 2 = (100 + 105) / 2 = 102.5
Assert.Equal(102.5, result.Open, Tolerance);
}
[Fact]
public void Update_FirstBar_HaHighIsMaxOfHOC()
{
var indicator = new Ha();
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000);
var result = indicator.UpdateBar(bar);
// HA High = max(110, 102.5, 101.25) = 110
Assert.Equal(110, result.High, Tolerance);
}
[Fact]
public void Update_FirstBar_HaLowIsMinOfLOC()
{
var indicator = new Ha();
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000);
var result = indicator.UpdateBar(bar);
// HA Low = min(90, 102.5, 101.25) = 90
Assert.Equal(90, result.Low, Tolerance);
}
[Fact]
public void Update_SecondBar_HaOpenIsRecursive()
{
var indicator = new Ha();
var time = DateTime.UtcNow;
// First bar: O=100, H=110, L=90, C=105
// HA_Open1 = (100+105)/2 = 102.5, HA_Close1 = 101.25
indicator.UpdateBar(new TBar(time, 100, 110, 90, 105, 1000));
// Second bar: O=105, H=115, L=95, C=110
// HA_Open2 = (prevHaOpen + prevHaClose) / 2 = (102.5 + 101.25) / 2 = 101.875
var result = indicator.UpdateBar(new TBar(time.AddMinutes(1), 105, 115, 95, 110, 1000));
Assert.Equal(101.875, result.Open, Tolerance);
}
[Fact]
public void Update_SecondBar_HaCloseIsOHLC4()
{
var indicator = new Ha();
var time = DateTime.UtcNow;
indicator.UpdateBar(new TBar(time, 100, 110, 90, 105, 1000));
var result = indicator.UpdateBar(new TBar(time.AddMinutes(1), 105, 115, 95, 110, 1000));
// HA Close = (105 + 115 + 95 + 110) / 4 = 106.25
Assert.Equal(106.25, result.Close, Tolerance);
}
[Fact]
public void Update_VolumePassthrough()
{
var indicator = new Ha();
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1234.5);
var result = indicator.UpdateBar(bar);
Assert.Equal(1234.5, result.Volume, Tolerance);
}
[Fact]
public void Update_TimePassthrough()
{
var indicator = new Ha();
var time = DateTime.UtcNow;
var bar = new TBar(time, 100, 110, 90, 105, 1000);
var result = indicator.UpdateBar(bar);
Assert.Equal(time.Ticks, result.Time);
}
[Fact]
public void Update_HaHighAlwaysGEHaOpenAndHaClose()
{
var indicator = new Ha();
var bars = GenerateBars(100);
for (int i = 0; i < bars.Count; i++)
{
var ha = indicator.UpdateBar(bars[i], isNew: true);
Assert.True(ha.High >= ha.Open, $"Bar {i}: High {ha.High} < Open {ha.Open}");
Assert.True(ha.High >= ha.Close, $"Bar {i}: High {ha.High} < Close {ha.Close}");
}
}
[Fact]
public void Update_HaLowAlwaysLEHaOpenAndHaClose()
{
var indicator = new Ha();
var bars = GenerateBars(100);
for (int i = 0; i < bars.Count; i++)
{
var ha = indicator.UpdateBar(bars[i], isNew: true);
Assert.True(ha.Low <= ha.Open, $"Bar {i}: Low {ha.Low} > Open {ha.Open}");
Assert.True(ha.Low <= ha.Close, $"Bar {i}: Low {ha.Low} > Close {ha.Close}");
}
}
[Fact]
public void Update_LastProperty_ReturnsHaClose()
{
var indicator = new Ha();
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000);
indicator.UpdateBar(bar);
// Last.Value should equal HA Close
Assert.Equal(101.25, indicator.Last.Value, Tolerance);
}
[Fact]
public void Update_LastBarProperty_ReturnsFullHaBar()
{
var indicator = new Ha();
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000);
var result = indicator.UpdateBar(bar);
Assert.Equal(result, indicator.LastBar);
}
#endregion
#region State and Bar Correction Tests
[Fact]
public void IsHot_AfterFirstBar_ReturnsTrue()
{
var indicator = new Ha();
Assert.False(indicator.IsHot);
indicator.UpdateBar(new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000));
Assert.True(indicator.IsHot);
}
[Fact]
public void Update_IsNewFalse_RestoresPreviousState()
{
var indicator = new Ha();
var time = DateTime.UtcNow;
// First bar
indicator.UpdateBar(new TBar(time, 100, 110, 90, 105, 1000), isNew: true);
// Second bar (new)
indicator.UpdateBar(new TBar(time.AddMinutes(1), 105, 115, 95, 110, 1000), isNew: true);
// Correction on second bar
var corrected = indicator.UpdateBar(new TBar(time.AddMinutes(1), 106, 116, 96, 111, 1000), isNew: false);
// Verify the HA Open is computed from first bar's HA values, not second bar's
// After first bar: prevHaOpen=102.5, prevHaClose=101.25
// Corrected HA_Open = (102.5 + 101.25)/2 = 101.875
Assert.Equal(101.875, corrected.Open, Tolerance);
// Corrected HA_Close = (106+116+96+111)/4 = 107.25
Assert.Equal(107.25, corrected.Close, Tolerance);
}
[Fact]
public void Update_MultipleIsNewFalse_ProducesIdempotentResults()
{
var indicator = new Ha();
var time = DateTime.UtcNow;
indicator.UpdateBar(new TBar(time, 100, 110, 90, 105, 1000), isNew: true);
var bar = new TBar(time.AddMinutes(1), 105, 115, 95, 110, 1000);
var result1 = indicator.UpdateBar(bar, isNew: false);
var result2 = indicator.UpdateBar(bar, isNew: false);
var result3 = indicator.UpdateBar(bar, isNew: false);
Assert.Equal(result1.Open, result2.Open, Tolerance);
Assert.Equal(result1.Close, result2.Close, Tolerance);
Assert.Equal(result1.High, result2.High, Tolerance);
Assert.Equal(result1.Low, result2.Low, Tolerance);
Assert.Equal(result2, result3);
}
[Fact]
public void Reset_ClearsState()
{
var indicator = new Ha();
indicator.UpdateBar(new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000));
Assert.True(indicator.IsHot);
indicator.Reset();
Assert.False(indicator.IsHot);
Assert.Equal(default, indicator.Last);
Assert.Equal(default, indicator.LastBar);
}
#endregion
#region NaN/Infinity Robustness Tests
[Fact]
public void Update_NaN_UsesLastValidValue()
{
var indicator = new Ha();
var time = DateTime.UtcNow;
// Valid bar first
indicator.UpdateBar(new TBar(time, 100, 110, 90, 105, 1000), isNew: true);
_ = indicator.LastBar;
// NaN bar — should substitute last valid values
var nanBar = new TBar(time.AddMinutes(1), double.NaN, double.NaN, double.NaN, double.NaN, 1000);
var result = indicator.UpdateBar(nanBar, isNew: true);
Assert.True(double.IsFinite(result.Open));
Assert.True(double.IsFinite(result.High));
Assert.True(double.IsFinite(result.Low));
Assert.True(double.IsFinite(result.Close));
}
[Fact]
public void Update_Infinity_UsesLastValidValue()
{
var indicator = new Ha();
var time = DateTime.UtcNow;
indicator.UpdateBar(new TBar(time, 100, 110, 90, 105, 1000), isNew: true);
var infBar = new TBar(time.AddMinutes(1), double.PositiveInfinity, double.NegativeInfinity, double.NaN, double.PositiveInfinity, 1000);
var result = indicator.UpdateBar(infBar, isNew: true);
Assert.True(double.IsFinite(result.Open));
Assert.True(double.IsFinite(result.High));
Assert.True(double.IsFinite(result.Low));
Assert.True(double.IsFinite(result.Close));
}
#endregion
#region Consistency Tests (All Modes)
[Fact]
public void AllModes_StreamingAndBatch_ProduceConsistentResults()
{
var bars = GenerateBars(100);
// Mode 1: Streaming
var streaming = new Ha();
TBar[] streamingResults = new TBar[bars.Count];
for (int i = 0; i < bars.Count; i++)
{
streamingResults[i] = streaming.UpdateBar(bars[i], isNew: true);
}
// Mode 2: Batch (TBarSeries)
var batchResult = Ha.Batch(bars);
// Mode 3: Span batch
double[] haOpenOut = new double[bars.Count];
double[] haHighOut = new double[bars.Count];
double[] haLowOut = new double[bars.Count];
double[] haCloseOut = new double[bars.Count];
Ha.Batch(bars.OpenValues, bars.HighValues, bars.LowValues, bars.CloseValues,
haOpenOut, haHighOut, haLowOut, haCloseOut);
for (int i = 0; i < bars.Count; i++)
{
Assert.Equal(streamingResults[i].Open, batchResult[i].Open, Tolerance);
Assert.Equal(streamingResults[i].High, batchResult[i].High, Tolerance);
Assert.Equal(streamingResults[i].Low, batchResult[i].Low, Tolerance);
Assert.Equal(streamingResults[i].Close, batchResult[i].Close, Tolerance);
Assert.Equal(streamingResults[i].Open, haOpenOut[i], Tolerance);
Assert.Equal(streamingResults[i].High, haHighOut[i], Tolerance);
Assert.Equal(streamingResults[i].Low, haLowOut[i], Tolerance);
Assert.Equal(streamingResults[i].Close, haCloseOut[i], Tolerance);
}
}
[Fact]
public void AllBars_HaCloseMatchesOHLC4()
{
var bars = GenerateBars(50);
var indicator = new Ha();
for (int i = 0; i < bars.Count; i++)
{
var result = indicator.UpdateBar(bars[i], isNew: true);
Assert.Equal(bars[i].OHLC4, result.Close, Tolerance);
}
}
#endregion
#region Batch Validation Tests
[Fact]
public void Batch_MismatchedLengths_ThrowsArgumentException()
{
double[] open = new double[10];
double[] high = new double[10];
double[] low = new double[5]; // mismatched
double[] close = new double[10];
double[] ho = new double[10], hh = new double[10], hl = new double[10], hc = new double[10];
var ex = Assert.Throws<ArgumentException>(() => Ha.Batch(open, high, low, close, ho, hh, hl, hc));
Assert.Equal("high", ex.ParamName);
}
[Fact]
public void Batch_OutputTooShort_ThrowsArgumentException()
{
double[] open = new double[10];
double[] high = new double[10];
double[] low = new double[10];
double[] close = new double[10];
double[] ho = new double[5]; // too short
double[] hh = new double[10], hl = new double[10], hc = new double[10];
var ex = Assert.Throws<ArgumentException>(() => Ha.Batch(open, high, low, close, ho, hh, hl, hc));
Assert.Equal("haOpenOut", ex.ParamName);
}
[Fact]
public void Batch_EmptyInput_NoOutput()
{
var bars = new TBarSeries();
var result = Ha.Batch(bars);
Assert.Empty(result);
}
[Fact]
public void Batch_LargeDataset_NoStackOverflow()
{
var bars = GenerateBars(10_000);
var result = Ha.Batch(bars);
Assert.Equal(bars.Count, result.Count);
Assert.True(double.IsFinite(result[^1].Close));
}
#endregion
#region HA-Specific Property Tests
[Fact]
public void ConstantInput_ConvergesToConstant()
{
var indicator = new Ha();
var time = DateTime.UtcNow;
// Feed constant bars: O=100, H=100, L=100, C=100
for (int i = 0; i < 20; i++)
{
_ = indicator.UpdateBar(new TBar(time.AddMinutes(i), 100, 100, 100, 100, 1000), isNew: true);
}
var last = indicator.LastBar;
// After many constant bars, all HA values should converge to 100
Assert.Equal(100.0, last.Open, 1e-6);
Assert.Equal(100.0, last.High, 1e-6);
Assert.Equal(100.0, last.Low, 1e-6);
Assert.Equal(100.0, last.Close, 1e-6);
}
[Fact]
public void HaHighGERealHigh_WhenBodyExceedsHigh()
{
// This tests the clamping: HA High is at least as large as HA Open and HA Close
var indicator = new Ha();
var bars = GenerateBars(100);
for (int i = 0; i < bars.Count; i++)
{
var ha = indicator.UpdateBar(bars[i], isNew: true);
// HA High should be >= real High OR >= haOpen/haClose
Assert.True(ha.High >= ha.Open);
Assert.True(ha.High >= ha.Close);
}
}
#endregion
#region Event Chaining Tests
[Fact]
public void Pub_EventFires_OnUpdate()
{
var indicator = new Ha();
bool fired = false;
indicator.Pub += (object? sender, in TValueEventArgs args) => fired = true;
indicator.UpdateBar(new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000));
Assert.True(fired);
}
[Fact]
public void Calculate_Static_ReturnsResultsAndIndicator()
{
var bars = GenerateBars(50);
var (results, ind) = Ha.Calculate(bars);
Assert.Equal(bars.Count, results.Count);
Assert.True(ind.IsHot);
}
#endregion
}
+411
View File
@@ -0,0 +1,411 @@
// Ha Validation Tests
// Cross-validates Heikin-Ashi against Skender.Stock.Indicators GetHeikinAshi()
// plus self-consistency tests for batch/streaming/span equivalence.
using System.Runtime.CompilerServices;
using Skender.Stock.Indicators;
using Xunit;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
/// <summary>
/// Validation for Ha (Heikin-Ashi) indicator.
/// Cross-validates all 4 OHLC channels against Skender GetHeikinAshi(),
/// plus self-consistency (batch == streaming == span), constant convergence,
/// formula verification, and bar correction.
/// </summary>
public sealed class HaValidationTests : IDisposable
{
private readonly ValidationTestData _data = new();
private readonly ITestOutputHelper _output;
private readonly GBM _gbm;
private bool _disposed;
private const double SelfTolerance = 1e-10;
private const int DataSize = 5000;
public HaValidationTests(ITestOutputHelper output)
{
_output = output;
_gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.5, seed: 42);
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
private void Dispose(bool disposing)
{
if (!_disposed && disposing)
{
_data.Dispose();
_disposed = true;
}
}
private TBarSeries GenerateBars(int count)
{
_gbm.Reset(DateTime.UtcNow.Ticks);
return _gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
}
// ════════════════════════════════════════════════════════════════════════
// Skender Cross-Validation Tests
// ════════════════════════════════════════════════════════════════════════
// ── A) Skender GetHeikinAshi batch validation (all 4 OHLC channels) ──
[Fact]
public void Validate_Against_Skender_HeikinAshi_Batch()
{
var skenderResults = _data.SkenderQuotes
.GetHeikinAshi()
.ToList();
var qlResult = Ha.Batch(_data.Bars);
Assert.Equal(qlResult.Count, skenderResults.Count);
int count = qlResult.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
int failures = 0;
for (int i = start; i < count; i++)
{
double qlOpen = qlResult[i].Open;
double qlHigh = qlResult[i].High;
double qlLow = qlResult[i].Low;
double qlClose = qlResult[i].Close;
double skOpen = (double)skenderResults[i].Open;
double skHigh = (double)skenderResults[i].High;
double skLow = (double)skenderResults[i].Low;
double skClose = (double)skenderResults[i].Close;
if (Math.Abs(qlOpen - skOpen) > ValidationHelper.SkenderTolerance)
{
_output.WriteLine($"Open mismatch at {i}: QL={qlOpen:G17}, SK={skOpen:G17}, Δ={Math.Abs(qlOpen - skOpen):G17}");
failures++;
}
if (Math.Abs(qlHigh - skHigh) > ValidationHelper.SkenderTolerance)
{
_output.WriteLine($"High mismatch at {i}: QL={qlHigh:G17}, SK={skHigh:G17}, Δ={Math.Abs(qlHigh - skHigh):G17}");
failures++;
}
if (Math.Abs(qlLow - skLow) > ValidationHelper.SkenderTolerance)
{
_output.WriteLine($"Low mismatch at {i}: QL={qlLow:G17}, SK={skLow:G17}, Δ={Math.Abs(qlLow - skLow):G17}");
failures++;
}
if (Math.Abs(qlClose - skClose) > ValidationHelper.SkenderTolerance)
{
_output.WriteLine($"Close mismatch at {i}: QL={qlClose:G17}, SK={skClose:G17}, Δ={Math.Abs(qlClose - skClose):G17}");
failures++;
}
}
Assert.True(failures == 0, $"Skender batch validation: {failures} OHLC channel mismatches in bars {start}..{count - 1}");
_output.WriteLine($"HA vs Skender GetHeikinAshi batch: {count} bars, last {count - start} verified (4 channels) within {ValidationHelper.SkenderTolerance}: PASSED");
}
// ── B) Skender GetHeikinAshi streaming validation ─────────────────────
[Fact]
public void Validate_Against_Skender_HeikinAshi_Streaming()
{
var skenderResults = _data.SkenderQuotes
.GetHeikinAshi()
.ToList();
var ind = new Ha();
int count = _data.Bars.Count;
double[] sOpen = new double[count];
double[] sHigh = new double[count];
double[] sLow = new double[count];
double[] sClose = new double[count];
for (int i = 0; i < count; i++)
{
var ha = ind.UpdateBar(_data.Bars[i], isNew: true);
sOpen[i] = ha.Open;
sHigh[i] = ha.High;
sLow[i] = ha.Low;
sClose[i] = ha.Close;
}
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
int failures = 0;
for (int i = start; i < count; i++)
{
double skOpen = (double)skenderResults[i].Open;
double skHigh = (double)skenderResults[i].High;
double skLow = (double)skenderResults[i].Low;
double skClose = (double)skenderResults[i].Close;
if (Math.Abs(sOpen[i] - skOpen) > ValidationHelper.SkenderTolerance)
{
_output.WriteLine($"Stream Open mismatch at {i}: QL={sOpen[i]:G17}, SK={skOpen:G17}");
failures++;
}
if (Math.Abs(sHigh[i] - skHigh) > ValidationHelper.SkenderTolerance)
{
_output.WriteLine($"Stream High mismatch at {i}: QL={sHigh[i]:G17}, SK={skHigh:G17}");
failures++;
}
if (Math.Abs(sLow[i] - skLow) > ValidationHelper.SkenderTolerance)
{
_output.WriteLine($"Stream Low mismatch at {i}: QL={sLow[i]:G17}, SK={skLow:G17}");
failures++;
}
if (Math.Abs(sClose[i] - skClose) > ValidationHelper.SkenderTolerance)
{
_output.WriteLine($"Stream Close mismatch at {i}: QL={sClose[i]:G17}, SK={skClose:G17}");
failures++;
}
}
Assert.True(failures == 0, $"Skender streaming validation: {failures} OHLC channel mismatches");
_output.WriteLine($"HA streaming vs Skender GetHeikinAshi: {count} bars, last {count - start} verified: PASSED");
}
// ── C) Skender GetHeikinAshi span validation ─────────────────────────
[Fact]
[SkipLocalsInit]
public void Validate_Against_Skender_HeikinAshi_Span()
{
var skenderResults = _data.SkenderQuotes
.GetHeikinAshi()
.ToList();
int count = _data.Bars.Count;
double[] o = new double[count];
double[] h = new double[count];
double[] l = new double[count];
double[] c = new double[count];
for (int i = 0; i < count; i++)
{
o[i] = _data.Bars[i].Open;
h[i] = _data.Bars[i].High;
l[i] = _data.Bars[i].Low;
c[i] = _data.Bars[i].Close;
}
double[] haO = new double[count];
double[] haH = new double[count];
double[] haL = new double[count];
double[] haC = new double[count];
Ha.Batch(o.AsSpan(), h.AsSpan(), l.AsSpan(), c.AsSpan(),
haO.AsSpan(), haH.AsSpan(), haL.AsSpan(), haC.AsSpan());
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
int failures = 0;
for (int i = start; i < count; i++)
{
double skOpen = (double)skenderResults[i].Open;
double skHigh = (double)skenderResults[i].High;
double skLow = (double)skenderResults[i].Low;
double skClose = (double)skenderResults[i].Close;
if (Math.Abs(haO[i] - skOpen) > ValidationHelper.SkenderTolerance)
{
_output.WriteLine($"Span Open mismatch at {i}: QL={haO[i]:G17}, SK={skOpen:G17}");
failures++;
}
if (Math.Abs(haH[i] - skHigh) > ValidationHelper.SkenderTolerance)
{
_output.WriteLine($"Span High mismatch at {i}: QL={haH[i]:G17}, SK={skHigh:G17}");
failures++;
}
if (Math.Abs(haL[i] - skLow) > ValidationHelper.SkenderTolerance)
{
_output.WriteLine($"Span Low mismatch at {i}: QL={haL[i]:G17}, SK={skLow:G17}");
failures++;
}
if (Math.Abs(haC[i] - skClose) > ValidationHelper.SkenderTolerance)
{
_output.WriteLine($"Span Close mismatch at {i}: QL={haC[i]:G17}, SK={skClose:G17}");
failures++;
}
}
Assert.True(failures == 0, $"Skender span validation: {failures} OHLC channel mismatches");
_output.WriteLine($"HA span vs Skender GetHeikinAshi: {count} bars, last {count - start} verified: PASSED");
}
// ════════════════════════════════════════════════════════════════════════
// Self-Consistency Tests
// ════════════════════════════════════════════════════════════════════════
// ── D) Batch == Streaming ─────────────────────────────────────────────
[Fact]
public void BatchAndStreaming_Match()
{
var bars = GenerateBars(DataSize);
// Streaming
var streaming = new Ha();
var streamingBars = new List<TBar>(DataSize);
for (int i = 0; i < bars.Count; i++)
{
streamingBars.Add(streaming.UpdateBar(bars[i], isNew: true));
}
// Batch
var batchResult = Ha.Batch(bars);
for (int i = 0; i < bars.Count; i++)
{
Assert.Equal(streamingBars[i].Open, batchResult[i].Open, SelfTolerance);
Assert.Equal(streamingBars[i].High, batchResult[i].High, SelfTolerance);
Assert.Equal(streamingBars[i].Low, batchResult[i].Low, SelfTolerance);
Assert.Equal(streamingBars[i].Close, batchResult[i].Close, SelfTolerance);
}
_output.WriteLine($"Batch == Streaming: {bars.Count} bars, all 4 OHLC channels matched within {SelfTolerance}: PASSED");
}
// ── E) Span == Streaming ──────────────────────────────────────────────
[Fact]
public void SpanAndStreaming_Match()
{
var bars = GenerateBars(DataSize);
// Streaming
var streaming = new Ha();
double[] sOpen = new double[bars.Count];
double[] sHigh = new double[bars.Count];
double[] sLow = new double[bars.Count];
double[] sClose = new double[bars.Count];
for (int i = 0; i < bars.Count; i++)
{
var ha = streaming.UpdateBar(bars[i], isNew: true);
sOpen[i] = ha.Open;
sHigh[i] = ha.High;
sLow[i] = ha.Low;
sClose[i] = ha.Close;
}
// Span batch
double[] haO = new double[bars.Count];
double[] haH = new double[bars.Count];
double[] haL = new double[bars.Count];
double[] haC = new double[bars.Count];
Ha.Batch(bars.OpenValues, bars.HighValues, bars.LowValues, bars.CloseValues,
haO, haH, haL, haC);
for (int i = 0; i < bars.Count; i++)
{
Assert.Equal(sOpen[i], haO[i], SelfTolerance);
Assert.Equal(sHigh[i], haH[i], SelfTolerance);
Assert.Equal(sLow[i], haL[i], SelfTolerance);
Assert.Equal(sClose[i], haC[i], SelfTolerance);
}
_output.WriteLine($"Span == Streaming: {bars.Count} bars matched within {SelfTolerance}: PASSED");
}
// ── F) Constant bars converge ─────────────────────────────────────────
[Fact]
public void ConstantBars_ConvergeToConstant()
{
var indicator = new Ha();
var time = DateTime.UtcNow;
double price = 50.0;
TBar last = default;
for (int i = 0; i < 100; i++)
{
last = indicator.UpdateBar(new TBar(time.AddMinutes(i), price, price, price, price, 1000), isNew: true);
}
Assert.Equal(price, last.Open, 1e-6);
Assert.Equal(price, last.High, 1e-6);
Assert.Equal(price, last.Low, 1e-6);
Assert.Equal(price, last.Close, 1e-6);
_output.WriteLine($"Constant convergence: price={price}, all OHLC matched: PASSED");
}
// ── G) HA Close always equals OHLC4 of source bar ─────────────────────
[Fact]
public void HaClose_AlwaysEqualsOHLC4()
{
var bars = GenerateBars(DataSize);
var indicator = new Ha();
for (int i = 0; i < bars.Count; i++)
{
var ha = indicator.UpdateBar(bars[i], isNew: true);
double expected = bars[i].OHLC4;
Assert.Equal(expected, ha.Close, SelfTolerance);
}
_output.WriteLine($"HA Close == source OHLC4: {bars.Count} bars verified: PASSED");
}
// ── H) HA High/Low always contain body ────────────────────────────────
[Fact]
public void HaHighLow_AlwaysContainBody()
{
var bars = GenerateBars(DataSize);
var indicator = new Ha();
for (int i = 0; i < bars.Count; i++)
{
var ha = indicator.UpdateBar(bars[i], isNew: true);
Assert.True(ha.High >= ha.Open, $"Bar {i}: High {ha.High} < Open {ha.Open}");
Assert.True(ha.High >= ha.Close, $"Bar {i}: High {ha.High} < Close {ha.Close}");
Assert.True(ha.Low <= ha.Open, $"Bar {i}: Low {ha.Low} > Open {ha.Open}");
Assert.True(ha.Low <= ha.Close, $"Bar {i}: Low {ha.Low} > Close {ha.Close}");
}
_output.WriteLine($"HA High/Low contain body: {bars.Count} bars verified: PASSED");
}
// ── I) Bar correction consistency ─────────────────────────────────────
[Fact]
public void BarCorrection_Consistency()
{
var bars = GenerateBars(100);
var indicator1 = new Ha();
var indicator2 = new Ha();
// Run indicator1 normally
for (int i = 0; i < bars.Count; i++)
{
indicator1.UpdateBar(bars[i], isNew: true);
}
// Run indicator2 with corrections
for (int i = 0; i < bars.Count; i++)
{
indicator2.UpdateBar(bars[i], isNew: true);
// Simulate correction
if (i > 0 && i % 5 == 0)
{
indicator2.UpdateBar(bars[i], isNew: false);
}
}
Assert.Equal(indicator1.LastBar.Open, indicator2.LastBar.Open, SelfTolerance);
Assert.Equal(indicator1.LastBar.Close, indicator2.LastBar.Close, SelfTolerance);
_output.WriteLine("Bar correction consistency: PASSED");
}
// ── J) Calculate returns hot indicator ─────────────────────────────────
[Fact]
public void Calculate_ReturnsHotIndicator()
{
var bars = GenerateBars(50);
var (results, indicator) = Ha.Calculate(bars);
Assert.True(indicator.IsHot);
Assert.Equal(bars.Count, results.Count);
_output.WriteLine($"Calculate returns hot indicator: {results.Count} bars, IsHot=true: PASSED");
}
}