Files
Miha Kralj 060649192f 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
2026-03-12 12:34:16 -07:00

509 lines
16 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
}
}