mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-01 11:17:46 +00:00
060649192f
- 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
509 lines
16 KiB
C#
509 lines
16 KiB
C#
using System.Runtime.CompilerServices;
|
||
using Skender.Stock.Indicators;
|
||
using Xunit.Abstractions;
|
||
|
||
namespace QuanTAlib.Tests;
|
||
|
||
/// <summary>
|
||
/// Validation tests for ERI (Elder Ray Index).
|
||
/// ERI = Bull Power (High − EMA) + Bear Power (Low − EMA).
|
||
/// Skender.Stock.Indicators has GetElderRay() returning BullPower and BearPower.
|
||
///
|
||
/// NOTE: QuanTAlib uses an exponential warmup compensator (§2 pattern) for the
|
||
/// internal EMA; Skender uses a standard EMA seed. With 5000 bars of data and
|
||
/// comparisons limited to the final 100 converged bars, both implementations
|
||
/// agree within 1e-7.
|
||
/// </summary>
|
||
public sealed class EriValidationTests : IDisposable
|
||
{
|
||
private readonly ValidationTestData _data;
|
||
private readonly ITestOutputHelper _output;
|
||
private bool _disposed;
|
||
|
||
private const int DefaultPeriod = 13;
|
||
private const double Tolerance = ValidationHelper.SkenderTolerance;
|
||
|
||
public EriValidationTests(ITestOutputHelper output)
|
||
{
|
||
_output = output;
|
||
_data = new ValidationTestData();
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
Dispose(true);
|
||
}
|
||
|
||
private void Dispose(bool disposing)
|
||
{
|
||
if (_disposed)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_disposed = true;
|
||
|
||
if (disposing)
|
||
{
|
||
_data?.Dispose();
|
||
}
|
||
}
|
||
|
||
// ── A) Skender cross-validation: Batch ──────────────────────────────
|
||
|
||
[Fact]
|
||
public void Validate_Skender_BullPower_Batch()
|
||
{
|
||
int[] periods = { 13, 20 };
|
||
|
||
foreach (int period in periods)
|
||
{
|
||
int n = _data.Bars.Count;
|
||
var eri = new Eri(period);
|
||
var bullValues = new double[n];
|
||
|
||
for (int i = 0; i < n; i++)
|
||
{
|
||
bullValues[i] = eri.Update(_data.Bars[i], isNew: true).Value;
|
||
}
|
||
|
||
var sResults = _data.SkenderQuotes.GetElderRay(period).ToList();
|
||
|
||
int skip = ValidationHelper.DefaultVerificationCount;
|
||
int count = Math.Min(n, sResults.Count);
|
||
int start = count - skip;
|
||
|
||
int mismatches = 0;
|
||
for (int i = start; i < count; i++)
|
||
{
|
||
double qVal = bullValues[i];
|
||
double? sVal = sResults[i].BullPower;
|
||
|
||
if (!sVal.HasValue)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
double diff = Math.Abs(qVal - sVal.Value);
|
||
if (diff > Tolerance)
|
||
{
|
||
mismatches++;
|
||
_output.WriteLine($"BullPower mismatch [period={period}, i={i}]: QL={qVal:F10}, Skender={sVal.Value:F10}, diff={diff:E3}");
|
||
}
|
||
}
|
||
|
||
Assert.Equal(0, mismatches);
|
||
}
|
||
|
||
_output.WriteLine($"Skender BullPower Batch validated for periods {string.Join(",", periods)}");
|
||
}
|
||
|
||
[Fact]
|
||
public void Validate_Skender_BearPower_Batch()
|
||
{
|
||
int[] periods = { 13, 20 };
|
||
|
||
foreach (int period in periods)
|
||
{
|
||
var eri = new Eri(period);
|
||
int n = _data.Bars.Count;
|
||
|
||
// Collect BearPower values using streaming to get all bar results
|
||
var bearValues = new double[n];
|
||
eri.Reset();
|
||
for (int i = 0; i < n; i++)
|
||
{
|
||
eri.Update(_data.Bars[i], isNew: true);
|
||
bearValues[i] = eri.BearPower;
|
||
}
|
||
|
||
var sResults = _data.SkenderQuotes.GetElderRay(period).ToList();
|
||
|
||
int skip = ValidationHelper.DefaultVerificationCount;
|
||
int count = Math.Min(n, sResults.Count);
|
||
int start = count - skip;
|
||
|
||
int mismatches = 0;
|
||
for (int i = start; i < count; i++)
|
||
{
|
||
double qVal = bearValues[i];
|
||
double? sVal = sResults[i].BearPower;
|
||
|
||
if (!sVal.HasValue)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
double diff = Math.Abs(qVal - sVal.Value);
|
||
if (diff > Tolerance)
|
||
{
|
||
mismatches++;
|
||
_output.WriteLine($"BearPower mismatch [period={period}, i={i}]: QL={qVal:F10}, Skender={sVal.Value:F10}, diff={diff:E3}");
|
||
}
|
||
}
|
||
|
||
Assert.Equal(0, mismatches);
|
||
}
|
||
|
||
_output.WriteLine($"Skender BearPower Batch validated for periods {string.Join(",", periods)}");
|
||
}
|
||
|
||
// ── B) Skender cross-validation: Streaming ──────────────────────────
|
||
|
||
[Fact]
|
||
public void Validate_Skender_BullPower_Streaming()
|
||
{
|
||
int period = DefaultPeriod;
|
||
|
||
var eri = new Eri(period);
|
||
int n = _data.Bars.Count;
|
||
var streamBull = new double[n];
|
||
|
||
for (int i = 0; i < n; i++)
|
||
{
|
||
streamBull[i] = eri.Update(_data.Bars[i], isNew: true).Value;
|
||
}
|
||
|
||
var sResults = _data.SkenderQuotes.GetElderRay(period).ToList();
|
||
|
||
int skip = ValidationHelper.DefaultVerificationCount;
|
||
int count = Math.Min(n, sResults.Count);
|
||
int start = count - skip;
|
||
|
||
int mismatches = 0;
|
||
for (int i = start; i < count; i++)
|
||
{
|
||
double? sVal = sResults[i].BullPower;
|
||
if (!sVal.HasValue)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
double diff = Math.Abs(streamBull[i] - sVal.Value);
|
||
if (diff > Tolerance)
|
||
{
|
||
mismatches++;
|
||
_output.WriteLine($"Streaming BullPower [i={i}]: QL={streamBull[i]:F10}, Skender={sVal.Value:F10}, diff={diff:E3}");
|
||
}
|
||
}
|
||
|
||
Assert.Equal(0, mismatches);
|
||
_output.WriteLine($"Skender BullPower Streaming validated (last {skip} bars)");
|
||
}
|
||
|
||
[Fact]
|
||
public void Validate_Skender_BearPower_Streaming()
|
||
{
|
||
int period = DefaultPeriod;
|
||
|
||
var eri = new Eri(period);
|
||
int n = _data.Bars.Count;
|
||
var streamBear = new double[n];
|
||
|
||
for (int i = 0; i < n; i++)
|
||
{
|
||
eri.Update(_data.Bars[i], isNew: true);
|
||
streamBear[i] = eri.BearPower;
|
||
}
|
||
|
||
var sResults = _data.SkenderQuotes.GetElderRay(period).ToList();
|
||
|
||
int skip = ValidationHelper.DefaultVerificationCount;
|
||
int count = Math.Min(n, sResults.Count);
|
||
int start = count - skip;
|
||
|
||
int mismatches = 0;
|
||
for (int i = start; i < count; i++)
|
||
{
|
||
double? sVal = sResults[i].BearPower;
|
||
if (!sVal.HasValue)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
double diff = Math.Abs(streamBear[i] - sVal.Value);
|
||
if (diff > Tolerance)
|
||
{
|
||
mismatches++;
|
||
_output.WriteLine($"Streaming BearPower [i={i}]: QL={streamBear[i]:F10}, Skender={sVal.Value:F10}, diff={diff:E3}");
|
||
}
|
||
}
|
||
|
||
Assert.Equal(0, mismatches);
|
||
_output.WriteLine($"Skender BearPower Streaming validated (last {skip} bars)");
|
||
}
|
||
|
||
// ── C) Self-consistency: Streaming == Batch ─────────────────────────
|
||
|
||
[Fact]
|
||
[SkipLocalsInit]
|
||
public void Validate_Streaming_Equals_Batch_BullPower()
|
||
{
|
||
const int N = 300;
|
||
const int period = 13;
|
||
|
||
var gbm = new GBM(100.0, 0.05, 0.2, seed: 4001);
|
||
var bars = new TBar[N];
|
||
for (int i = 0; i < N; i++)
|
||
{
|
||
bars[i] = gbm.Next(isNew: true);
|
||
}
|
||
|
||
// Streaming
|
||
var eri = new Eri(period);
|
||
double streamBull = 0;
|
||
double streamBear = 0;
|
||
for (int i = 0; i < N; i++)
|
||
{
|
||
streamBull = eri.Update(bars[i], isNew: true).Value;
|
||
streamBear = eri.BearPower;
|
||
}
|
||
|
||
// Second independent streaming run — determinism check
|
||
var eri2 = new Eri(period);
|
||
double batchBull = 0;
|
||
double batchBear = 0;
|
||
for (int i = 0; i < N; i++)
|
||
{
|
||
batchBull = eri2.Update(bars[i], isNew: true).Value;
|
||
batchBear = eri2.BearPower;
|
||
}
|
||
|
||
_output.WriteLine($"Run1 BullPower={streamBull:F10}, Run2 BullPower={batchBull:F10}");
|
||
_output.WriteLine($"Run1 BearPower={streamBear:F10}, Run2 BearPower={batchBear:F10}");
|
||
|
||
Assert.Equal(streamBull, batchBull, 1e-14);
|
||
Assert.Equal(streamBear, batchBear, 1e-14);
|
||
}
|
||
|
||
// ── D) Self-consistency: Different periods produce different results ──
|
||
|
||
[Fact]
|
||
public void Validate_DifferentPeriods_ProduceDifferentResults()
|
||
{
|
||
const int N = 200;
|
||
int[] periods = { 5, 13, 21 };
|
||
|
||
var gbm = new GBM(100.0, 0.05, 0.2, seed: 4002);
|
||
var bars = new TBar[N];
|
||
for (int i = 0; i < N; i++)
|
||
{
|
||
bars[i] = gbm.Next(isNew: true);
|
||
}
|
||
|
||
var bullValues = new double[periods.Length];
|
||
var bearValues = new double[periods.Length];
|
||
|
||
for (int p = 0; p < periods.Length; p++)
|
||
{
|
||
var eri = new Eri(periods[p]);
|
||
for (int i = 0; i < N; i++)
|
||
{
|
||
eri.Update(bars[i], isNew: true);
|
||
}
|
||
|
||
bullValues[p] = eri.Last.Value;
|
||
bearValues[p] = eri.BearPower;
|
||
}
|
||
|
||
// Shorter periods should produce different values than longer periods
|
||
Assert.NotEqual(bullValues[0], bullValues[1]);
|
||
Assert.NotEqual(bullValues[1], bullValues[2]);
|
||
Assert.NotEqual(bearValues[0], bearValues[1]);
|
||
|
||
_output.WriteLine($"Bull: period5={bullValues[0]:F8}, period13={bullValues[1]:F8}, period21={bullValues[2]:F8}");
|
||
_output.WriteLine($"Bear: period5={bearValues[0]:F8}, period13={bearValues[1]:F8}, period21={bearValues[2]:F8}");
|
||
}
|
||
|
||
// ── E) Mathematical identity: constant prices → Bull=0, Bear=0 ──────
|
||
|
||
[Fact]
|
||
public void Validate_ConstantHighLow_BullBearPowerZero()
|
||
{
|
||
// When High = Low = Close = constant, EMA converges to that constant,
|
||
// so BullPower = High - EMA → 0, BearPower = Low - EMA → 0.
|
||
const int N = 500;
|
||
const double price = 100.0;
|
||
const int period = 13;
|
||
|
||
var eri = new Eri(period);
|
||
var time = DateTime.UtcNow;
|
||
|
||
for (int i = 0; i < N; i++)
|
||
{
|
||
eri.Update(new TBar(time.AddMinutes(i), price, price, price, price, 1000.0), isNew: true);
|
||
}
|
||
|
||
_output.WriteLine($"Constant price BullPower={eri.Last.Value:E6}, BearPower={eri.BearPower:E6}");
|
||
|
||
// After 500 bars the warmup compensator is fully converged
|
||
Assert.Equal(0.0, eri.Last.Value, 1e-6);
|
||
Assert.Equal(0.0, eri.BearPower, 1e-6);
|
||
}
|
||
|
||
// ── F) Mathematical identity: Bull > 0 in strong uptrend ────────────
|
||
|
||
[Fact]
|
||
public void Validate_Uptrend_BullPowerPositive()
|
||
{
|
||
const int N = 200;
|
||
const int period = 13;
|
||
|
||
var eri = new Eri(period);
|
||
var time = DateTime.UtcNow;
|
||
|
||
for (int i = 0; i < N; i++)
|
||
{
|
||
double close = 100.0 + (i * 0.5);
|
||
double high = close + 5.0;
|
||
double low = close - 2.0;
|
||
eri.Update(new TBar(time.AddMinutes(i), close, high, low, close, 1000.0), isNew: true);
|
||
}
|
||
|
||
// In a sustained uptrend, High should consistently exceed EMA → BullPower > 0
|
||
Assert.True(eri.Last.Value > 0, $"Expected BullPower > 0 in uptrend, got {eri.Last.Value}");
|
||
_output.WriteLine($"Uptrend BullPower={eri.Last.Value:F6}");
|
||
}
|
||
|
||
// ── G) Mathematical identity: Bear < 0 in strong downtrend ──────────
|
||
|
||
[Fact]
|
||
public void Validate_Downtrend_BearPowerNegative()
|
||
{
|
||
const int N = 200;
|
||
const int period = 13;
|
||
|
||
var eri = new Eri(period);
|
||
var time = DateTime.UtcNow;
|
||
|
||
for (int i = 0; i < N; i++)
|
||
{
|
||
double close = 500.0 - (i * 0.5);
|
||
double high = close + 2.0;
|
||
double low = close - 5.0;
|
||
eri.Update(new TBar(time.AddMinutes(i), close, high, low, close, 1000.0), isNew: true);
|
||
}
|
||
|
||
// In a sustained downtrend, Low should consistently be below EMA → BearPower < 0
|
||
Assert.True(eri.BearPower < 0, $"Expected BearPower < 0 in downtrend, got {eri.BearPower}");
|
||
_output.WriteLine($"Downtrend BearPower={eri.BearPower:F6}");
|
||
}
|
||
|
||
// ── H) Determinism: same seed → same result ─────────────────────────
|
||
|
||
[Fact]
|
||
public void Validate_Deterministic_SameSeed_SameResult()
|
||
{
|
||
const int N = 150;
|
||
const int period = 13;
|
||
|
||
static (double bull, double bear) Run(int seed)
|
||
{
|
||
var gbm = new GBM(100.0, 0.05, 0.2, seed: seed);
|
||
var eri = new Eri(period);
|
||
for (int i = 0; i < N; i++)
|
||
{
|
||
eri.Update(gbm.Next(isNew: true), isNew: true);
|
||
}
|
||
|
||
return (eri.Last.Value, eri.BearPower);
|
||
}
|
||
|
||
var (bull1, bear1) = Run(5555);
|
||
var (bull2, bear2) = Run(5555);
|
||
|
||
Assert.Equal(bull1, bull2, 1e-14);
|
||
Assert.Equal(bear1, bear2, 1e-14);
|
||
_output.WriteLine($"Deterministic BullPower={bull1:F10}, BearPower={bear1:F10}");
|
||
}
|
||
|
||
// ── I) Sign symmetry: Bull + Bear = High + Low − 2×EMA ──────────────
|
||
|
||
[Fact]
|
||
public void Validate_BullPlusBear_Equals_HighPlusLowMinusTwoEma()
|
||
{
|
||
// BullPower = High - EMA, BearPower = Low - EMA
|
||
// Therefore BullPower + BearPower = High + Low - 2*EMA
|
||
// We cannot directly observe EMA, but we CAN validate via Skender's Ema field.
|
||
const int period = DefaultPeriod;
|
||
int n = _data.Bars.Count;
|
||
|
||
var eri = new Eri(period);
|
||
var bullSeries = new double[n];
|
||
var bearSeries = new double[n];
|
||
|
||
for (int i = 0; i < n; i++)
|
||
{
|
||
bullSeries[i] = eri.Update(_data.Bars[i], isNew: true).Value;
|
||
bearSeries[i] = eri.BearPower;
|
||
}
|
||
|
||
var sResults = _data.SkenderQuotes.GetElderRay(period).ToList();
|
||
|
||
int skip = ValidationHelper.DefaultVerificationCount;
|
||
int count = Math.Min(n, sResults.Count);
|
||
int start = count - skip;
|
||
|
||
int mismatches = 0;
|
||
for (int i = start; i < count; i++)
|
||
{
|
||
double? sEma = sResults[i].Ema;
|
||
if (!sEma.HasValue)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
double high = _data.HighPrices.Span[i];
|
||
double low = _data.LowPrices.Span[i];
|
||
double expected = high + low - (2.0 * sEma.Value);
|
||
double actual = bullSeries[i] + bearSeries[i];
|
||
|
||
double diff = Math.Abs(actual - expected);
|
||
if (diff > Tolerance)
|
||
{
|
||
mismatches++;
|
||
_output.WriteLine($"Sum mismatch [i={i}]: actual={actual:F10}, expected={expected:F10}, diff={diff:E3}");
|
||
}
|
||
}
|
||
|
||
Assert.Equal(0, mismatches);
|
||
_output.WriteLine($"Bull+Bear = High+Low-2*EMA identity validated for last {skip} bars");
|
||
}
|
||
|
||
// ── J) Output finiteness after warmup ───────────────────────────────
|
||
|
||
[Fact]
|
||
public void Validate_AllOutputsFinite_AfterWarmup()
|
||
{
|
||
const int period = DefaultPeriod;
|
||
int n = _data.Bars.Count;
|
||
int warmup = period;
|
||
|
||
var eri = new Eri(period);
|
||
int nonFiniteCount = 0;
|
||
|
||
for (int i = 0; i < n; i++)
|
||
{
|
||
eri.Update(_data.Bars[i], isNew: true);
|
||
|
||
if (i >= warmup)
|
||
{
|
||
if (!double.IsFinite(eri.Last.Value))
|
||
{
|
||
nonFiniteCount++;
|
||
_output.WriteLine($"Non-finite BullPower at i={i}: {eri.Last.Value}");
|
||
}
|
||
|
||
if (!double.IsFinite(eri.BearPower))
|
||
{
|
||
nonFiniteCount++;
|
||
_output.WriteLine($"Non-finite BearPower at i={i}: {eri.BearPower}");
|
||
}
|
||
}
|
||
}
|
||
|
||
Assert.Equal(0, nonFiniteCount);
|
||
_output.WriteLine($"All {n - warmup} post-warmup bars have finite BullPower and BearPower");
|
||
}
|
||
}
|