mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-04 04:07:42 +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
347 lines
10 KiB
C#
347 lines
10 KiB
C#
using Skender.Stock.Indicators;
|
|
using Xunit;
|
|
using Xunit.Abstractions;
|
|
|
|
namespace QuanTAlib.Tests;
|
|
|
|
/// <summary>
|
|
/// Validation tests for ATRN (Average True Range Normalized).
|
|
/// ATRN is QuanTAlib-specific - it normalizes ATR to [0,1] using min-max scaling.
|
|
/// Validation focuses on:
|
|
/// 1. Underlying ATR matches external libraries
|
|
/// 2. Normalization logic is correct
|
|
/// 3. Output is always in [0,1] range
|
|
/// </summary>
|
|
public sealed class AtrnValidationTests : IDisposable
|
|
{
|
|
private readonly ValidationTestData _testData;
|
|
private readonly ITestOutputHelper _output;
|
|
private bool _disposed;
|
|
|
|
public AtrnValidationTests(ITestOutputHelper output)
|
|
{
|
|
_output = output;
|
|
_testData = new ValidationTestData();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Dispose(true);
|
|
}
|
|
|
|
private void Dispose(bool disposing)
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_disposed = true;
|
|
|
|
if (disposing)
|
|
{
|
|
_testData?.Dispose();
|
|
}
|
|
}
|
|
|
|
#region ATR Foundation Validation
|
|
|
|
/// <summary>
|
|
/// Validates that the underlying ATR calculation matches Skender.
|
|
/// Since ATRN = normalized(ATR), the ATR component must be accurate.
|
|
/// </summary>
|
|
[Fact]
|
|
public void UnderlyingAtr_MatchesSkender()
|
|
{
|
|
int period = 14;
|
|
|
|
// Get QuanTAlib ATR
|
|
var atr = new Atr(period);
|
|
var quantalibAtr = atr.Update(_testData.Bars);
|
|
|
|
// Get Skender ATR
|
|
var skenderResults = _testData.SkenderQuotes.GetAtr(period).ToList();
|
|
|
|
// Compare using ValidationHelper
|
|
ValidationHelper.VerifyData(quantalibAtr, skenderResults, (s) => s.Atr, tolerance: ValidationHelper.SkenderTolerance);
|
|
_output.WriteLine("Underlying ATR validated successfully against Skender");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Normalization Validation
|
|
|
|
/// <summary>
|
|
/// Validates that ATRN output is always in [0,1] range.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Atrn_AlwaysInZeroOneRange()
|
|
{
|
|
int period = 14;
|
|
var atrn = new Atrn(period);
|
|
|
|
for (int i = 0; i < _testData.Bars.Count; i++)
|
|
{
|
|
var result = atrn.Update(_testData.Bars[i], true);
|
|
|
|
Assert.True(result.Value >= 0.0,
|
|
$"ATRN at index {i} is {result.Value}, expected >= 0");
|
|
Assert.True(result.Value <= 1.0,
|
|
$"ATRN at index {i} is {result.Value}, expected <= 1");
|
|
}
|
|
_output.WriteLine("ATRN output range validated [0,1]");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates the min-max normalization formula.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Atrn_NormalizationFormula_IsCorrect()
|
|
{
|
|
int period = 14;
|
|
int lookbackWindow = 10 * period;
|
|
|
|
var atr = new Atr(period);
|
|
var atrn = new Atrn(period);
|
|
|
|
var atrValues = new List<double>();
|
|
|
|
for (int i = 0; i < _testData.Bars.Count; i++)
|
|
{
|
|
var atrResult = atr.Update(_testData.Bars[i], true);
|
|
atrValues.Add(atrResult.Value);
|
|
|
|
var atrnResult = atrn.Update(_testData.Bars[i], true);
|
|
|
|
// After warmup, verify normalization
|
|
if (i >= lookbackWindow)
|
|
{
|
|
// Get min/max of ATR over lookback window
|
|
int startIdx = Math.Max(0, atrValues.Count - lookbackWindow);
|
|
double minAtr = double.MaxValue;
|
|
double maxAtr = double.MinValue;
|
|
|
|
for (int j = startIdx; j < atrValues.Count; j++)
|
|
{
|
|
if (atrValues[j] < minAtr)
|
|
{
|
|
minAtr = atrValues[j];
|
|
}
|
|
|
|
if (atrValues[j] > maxAtr)
|
|
{
|
|
maxAtr = atrValues[j];
|
|
}
|
|
}
|
|
|
|
double currentAtr = atrValues[^1];
|
|
double expectedNormalized = minAtr < maxAtr
|
|
? (currentAtr - minAtr) / (maxAtr - minAtr)
|
|
: 0.5;
|
|
|
|
Assert.True(
|
|
Math.Abs(expectedNormalized - atrnResult.Value) < 1e-6,
|
|
$"Normalization mismatch at index {i}: expected={expectedNormalized}, actual={atrnResult.Value}"
|
|
);
|
|
}
|
|
}
|
|
_output.WriteLine("ATRN normalization formula validated");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates that constant ATR produces stable normalized value in [0,1].
|
|
/// </summary>
|
|
[Fact]
|
|
public void Atrn_ConstantAtr_ReturnsStableValue()
|
|
{
|
|
int period = 14;
|
|
var atrn = new Atrn(period);
|
|
int lookbackWindow = 10 * period;
|
|
|
|
// Create bars with constant range (no gaps, constant high-low)
|
|
var constantBars = new TBarSeries();
|
|
double price = 100.0;
|
|
long startTime = DateTime.UtcNow.Ticks;
|
|
|
|
for (int i = 0; i < lookbackWindow + 100; i++)
|
|
{
|
|
constantBars.Add(new TBar(
|
|
startTime + i * TimeSpan.FromMinutes(1).Ticks,
|
|
price, // Open
|
|
price + 5.0, // High (constant +5)
|
|
price - 5.0, // Low (constant -5)
|
|
price, // Close (same as open, no gap)
|
|
1000.0 // Volume
|
|
));
|
|
}
|
|
|
|
TValue lastResult = default;
|
|
for (int i = 0; i < constantBars.Count; i++)
|
|
{
|
|
lastResult = atrn.Update(constantBars[i], true);
|
|
}
|
|
|
|
// With constant volatility, value should be stable and within [0,1]
|
|
Assert.True(
|
|
lastResult.Value >= 0.0 && lastResult.Value <= 1.0,
|
|
$"Expected value in [0,1] for constant ATR, got {lastResult.Value}"
|
|
);
|
|
_output.WriteLine("ATRN constant ATR returns stable value validated");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Edge Cases
|
|
|
|
/// <summary>
|
|
/// Validates ATRN behavior with increasing volatility.
|
|
/// Higher current ATR relative to history should produce values closer to 1.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Atrn_IncreasingVolatility_ApproachesOne()
|
|
{
|
|
int period = 14;
|
|
var atrn = new Atrn(period);
|
|
int lookbackWindow = 10 * period;
|
|
|
|
// Create bars with increasing volatility
|
|
var bars = new TBarSeries();
|
|
double price = 100.0;
|
|
long startTime = DateTime.UtcNow.Ticks;
|
|
|
|
for (int i = 0; i < lookbackWindow + 50; i++)
|
|
{
|
|
// Range increases over time
|
|
double range = 1.0 + (i * 0.1);
|
|
|
|
bars.Add(new TBar(
|
|
startTime + i * TimeSpan.FromMinutes(1).Ticks,
|
|
price,
|
|
price + range,
|
|
price - range,
|
|
price,
|
|
1000.0
|
|
));
|
|
}
|
|
|
|
TValue lastResult = default;
|
|
for (int i = 0; i < bars.Count; i++)
|
|
{
|
|
lastResult = atrn.Update(bars[i], true);
|
|
}
|
|
|
|
// With increasing volatility, the latest ATR should be near max
|
|
// So normalized value should be close to 1
|
|
Assert.True(
|
|
lastResult.Value > 0.8,
|
|
$"Expected value close to 1.0 for increasing volatility, got {lastResult.Value}"
|
|
);
|
|
_output.WriteLine("ATRN increasing volatility validated");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates ATRN behavior with decreasing volatility.
|
|
/// Lower current ATR relative to history should produce values closer to 0.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Atrn_DecreasingVolatility_ApproachesZero()
|
|
{
|
|
int period = 14;
|
|
var atrn = new Atrn(period);
|
|
int lookbackWindow = 10 * period;
|
|
|
|
// Create bars with decreasing volatility
|
|
var bars = new TBarSeries();
|
|
double price = 100.0;
|
|
long startTime = DateTime.UtcNow.Ticks;
|
|
|
|
for (int i = 0; i < lookbackWindow + 50; i++)
|
|
{
|
|
// Range decreases over time (but stays positive)
|
|
double range = Math.Max(0.1, 10.0 - (i * 0.05));
|
|
|
|
bars.Add(new TBar(
|
|
startTime + i * TimeSpan.FromMinutes(1).Ticks,
|
|
price,
|
|
price + range,
|
|
price - range,
|
|
price,
|
|
1000.0
|
|
));
|
|
}
|
|
|
|
TValue lastResult = default;
|
|
for (int i = 0; i < bars.Count; i++)
|
|
{
|
|
lastResult = atrn.Update(bars[i], true);
|
|
}
|
|
|
|
// With decreasing volatility, the latest ATR should be near min
|
|
// So normalized value should be close to 0
|
|
Assert.True(
|
|
lastResult.Value < 0.2,
|
|
$"Expected value close to 0.0 for decreasing volatility, got {lastResult.Value}"
|
|
);
|
|
_output.WriteLine("ATRN decreasing volatility validated");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates different period settings produce valid results.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(5)]
|
|
[InlineData(10)]
|
|
[InlineData(14)]
|
|
[InlineData(20)]
|
|
[InlineData(50)]
|
|
public void Atrn_DifferentPeriods_ProducesValidResults(int period)
|
|
{
|
|
var atrn = new Atrn(period);
|
|
|
|
for (int i = 0; i < _testData.Bars.Count; i++)
|
|
{
|
|
var result = atrn.Update(_testData.Bars[i], true);
|
|
|
|
Assert.True(result.Value >= 0.0 && result.Value <= 1.0,
|
|
$"ATRN({period}) at index {i} is {result.Value}, expected in [0,1]");
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Streaming vs Batch Consistency
|
|
|
|
/// <summary>
|
|
/// Validates streaming matches batch calculation.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Atrn_StreamingMatchesBatch()
|
|
{
|
|
int period = 14;
|
|
|
|
// Streaming
|
|
var streamingAtrn = new Atrn(period);
|
|
var streamingResults = new List<double>();
|
|
|
|
for (int i = 0; i < _testData.Bars.Count; i++)
|
|
{
|
|
var result = streamingAtrn.Update(_testData.Bars[i], true);
|
|
streamingResults.Add(result.Value);
|
|
}
|
|
|
|
// Batch
|
|
var batchResults = Atrn.Batch(_testData.Bars, period);
|
|
|
|
Assert.Equal(streamingResults.Count, batchResults.Count);
|
|
|
|
// Compare all values
|
|
for (int i = 0; i < streamingResults.Count; i++)
|
|
{
|
|
Assert.Equal(streamingResults[i], batchResults[i].Value, 1e-10);
|
|
}
|
|
_output.WriteLine("ATRN streaming matches batch validated");
|
|
}
|
|
|
|
#endregion
|
|
}
|