mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-03 19:57:44 +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
631 lines
16 KiB
C#
631 lines
16 KiB
C#
using Xunit;
|
|
|
|
namespace QuanTAlib.Tests;
|
|
|
|
public sealed class WillrConstructorTests
|
|
{
|
|
[Fact]
|
|
public void DefaultPeriod_Is14()
|
|
{
|
|
var w = new Willr();
|
|
Assert.Equal(14, w.Period);
|
|
}
|
|
|
|
[Fact]
|
|
public void CustomPeriod_IsStored()
|
|
{
|
|
var w = new Willr(period: 20);
|
|
Assert.Equal(20, w.Period);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0)]
|
|
[InlineData(-1)]
|
|
[InlineData(-100)]
|
|
public void InvalidPeriod_Throws(int period)
|
|
{
|
|
var ex = Assert.Throws<ArgumentException>(() => new Willr(period));
|
|
Assert.Equal("period", ex.ParamName);
|
|
}
|
|
|
|
[Fact]
|
|
public void MinimumPeriod_IsOne()
|
|
{
|
|
var w = new Willr(period: 1);
|
|
Assert.Equal(1, w.Period);
|
|
}
|
|
|
|
[Fact]
|
|
public void Name_IncludesPeriod()
|
|
{
|
|
var w = new Willr(period: 10);
|
|
Assert.Contains("10", w.Name, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void WarmupPeriod_EqualsPeriod()
|
|
{
|
|
Assert.Equal(14, new Willr(14).WarmupPeriod);
|
|
Assert.Equal(5, new Willr(5).WarmupPeriod);
|
|
}
|
|
}
|
|
|
|
public sealed class WillrBasicTests
|
|
{
|
|
[Fact]
|
|
public void Update_Returns_TValue()
|
|
{
|
|
var w = new Willr();
|
|
var result = w.Update(new TValue(DateTime.UtcNow.Ticks, 100.0));
|
|
Assert.True(double.IsFinite(result.Value));
|
|
}
|
|
|
|
[Fact]
|
|
public void Last_IsAccessible()
|
|
{
|
|
var w = new Willr();
|
|
_ = w.Update(new TValue(DateTime.UtcNow.Ticks, 100.0));
|
|
Assert.True(double.IsFinite(w.Last.Value));
|
|
}
|
|
|
|
[Fact]
|
|
public void IsHot_AfterWarmup()
|
|
{
|
|
var w = new Willr(period: 5);
|
|
var time = DateTime.UtcNow;
|
|
|
|
for (int i = 0; i < 4; i++)
|
|
{
|
|
w.Update(new TBar(time.AddMinutes(i), 100 + i, 105 + i, 95 + i, 102 + i, 100), isNew: true);
|
|
}
|
|
Assert.False(w.IsHot);
|
|
|
|
w.Update(new TBar(time.AddMinutes(4), 104, 109, 99, 106, 100), isNew: true);
|
|
Assert.True(w.IsHot);
|
|
}
|
|
|
|
[Fact]
|
|
public void Name_IsNotNull()
|
|
{
|
|
var w = new Willr();
|
|
Assert.NotNull(w.Name);
|
|
Assert.NotEmpty(w.Name);
|
|
}
|
|
}
|
|
|
|
public sealed class WillrRangeTests
|
|
{
|
|
[Fact]
|
|
public void CloseAtHighest_ValueIsZero()
|
|
{
|
|
var w = new Willr(period: 5);
|
|
var time = DateTime.UtcNow;
|
|
|
|
for (int i = 0; i < 5; i++)
|
|
{
|
|
w.Update(new TBar(time.AddMinutes(i), 100, 110, 90, 100, 100), isNew: true);
|
|
}
|
|
|
|
// Close at highest high (110)
|
|
w.Update(new TBar(time.AddMinutes(5), 110, 110, 90, 110, 100), isNew: true);
|
|
Assert.Equal(0.0, w.Last.Value, 1e-10);
|
|
}
|
|
|
|
[Fact]
|
|
public void CloseAtLowest_ValueIsNeg100()
|
|
{
|
|
var w = new Willr(period: 5);
|
|
var time = DateTime.UtcNow;
|
|
|
|
for (int i = 0; i < 5; i++)
|
|
{
|
|
w.Update(new TBar(time.AddMinutes(i), 100, 110, 90, 100, 100), isNew: true);
|
|
}
|
|
|
|
// Close at lowest low (90)
|
|
w.Update(new TBar(time.AddMinutes(5), 90, 110, 90, 90, 100), isNew: true);
|
|
Assert.Equal(-100.0, w.Last.Value, 1e-10);
|
|
}
|
|
|
|
[Fact]
|
|
public void CloseAtMidpoint_ValueIsNeg50()
|
|
{
|
|
var w = new Willr(period: 5);
|
|
var time = DateTime.UtcNow;
|
|
|
|
for (int i = 0; i < 5; i++)
|
|
{
|
|
w.Update(new TBar(time.AddMinutes(i), 100, 110, 90, 100, 100), isNew: true);
|
|
}
|
|
|
|
// Close at midpoint of range (100 = midpoint of 90-110)
|
|
w.Update(new TBar(time.AddMinutes(5), 100, 110, 90, 100, 100), isNew: true);
|
|
Assert.Equal(-50.0, w.Last.Value, 1e-10);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConstantBars_ValueIsNeg50()
|
|
{
|
|
var w = new Willr(period: 5);
|
|
var time = DateTime.UtcNow;
|
|
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
w.Update(new TBar(time.AddMinutes(i), 100, 100, 100, 100, 100), isNew: true);
|
|
}
|
|
|
|
// Range=0, should return -50
|
|
Assert.Equal(-50.0, w.Last.Value, 1e-10);
|
|
}
|
|
|
|
[Fact]
|
|
public void Rising_Produces_NearZero()
|
|
{
|
|
var w = new Willr(period: 5);
|
|
var time = DateTime.UtcNow;
|
|
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
double price = 100.0 + (i * 2.0);
|
|
w.Update(new TBar(time.AddMinutes(i), price, price + 1, price - 1, price + 1, 100), isNew: true);
|
|
}
|
|
|
|
// Close at recent high → WillR should be near 0 (> -20)
|
|
Assert.True(w.Last.Value > -20.0);
|
|
}
|
|
|
|
[Fact]
|
|
public void Falling_Produces_NearNeg100()
|
|
{
|
|
var w = new Willr(period: 5);
|
|
var time = DateTime.UtcNow;
|
|
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
double price = 200.0 - (i * 2.0);
|
|
w.Update(new TBar(time.AddMinutes(i), price, price + 1, price - 1, price - 1, 100), isNew: true);
|
|
}
|
|
|
|
// Close at recent low → WillR should be near -100 (< -80)
|
|
Assert.True(w.Last.Value < -80.0);
|
|
}
|
|
}
|
|
|
|
public sealed class WillrBarCorrectionTests
|
|
{
|
|
[Fact]
|
|
public void IsNew_True_AdvancesState()
|
|
{
|
|
var w = new Willr(period: 5);
|
|
var time = DateTime.UtcNow;
|
|
|
|
var bar1 = new TBar(time, 100, 105, 95, 100, 100);
|
|
var bar2 = new TBar(time.AddMinutes(1), 102, 108, 98, 104, 100);
|
|
|
|
w.Update(bar1, isNew: true);
|
|
var v1 = w.Last.Value;
|
|
|
|
w.Update(bar2, isNew: true);
|
|
var v2 = w.Last.Value;
|
|
|
|
Assert.NotEqual(v1, v2);
|
|
}
|
|
|
|
[Fact]
|
|
public void IsNew_False_RewritesCurrent()
|
|
{
|
|
var w = new Willr(period: 5);
|
|
var time = DateTime.UtcNow;
|
|
|
|
w.Update(new TBar(time, 100, 105, 95, 100, 100), isNew: true);
|
|
|
|
w.Update(new TBar(time.AddMinutes(1), 102, 108, 98, 104, 100), isNew: true);
|
|
var beforeCorrection = w.Last.Value;
|
|
|
|
// Correct current bar (isNew=false)
|
|
w.Update(new TBar(time.AddMinutes(1), 110, 115, 98, 112, 100), isNew: false);
|
|
var afterCorrection = w.Last.Value;
|
|
|
|
Assert.NotEqual(beforeCorrection, afterCorrection);
|
|
}
|
|
|
|
[Fact]
|
|
public void IterativeCorrections_Restore()
|
|
{
|
|
var w = new Willr(period: 5);
|
|
var time = DateTime.UtcNow;
|
|
|
|
// Feed 3 bars
|
|
for (int i = 0; i < 3; i++)
|
|
{
|
|
w.Update(new TBar(time.AddMinutes(i), 100 + i, 105 + i, 95 + i, 102 + i, 100), isNew: true);
|
|
}
|
|
|
|
// Add a new bar
|
|
w.Update(new TBar(time.AddMinutes(3), 103, 108, 98, 105, 100), isNew: true);
|
|
var original = w.Last.Value;
|
|
|
|
// Correct it several times (isNew=false)
|
|
w.Update(new TBar(time.AddMinutes(3), 110, 115, 98, 112, 100), isNew: false);
|
|
w.Update(new TBar(time.AddMinutes(3), 90, 115, 85, 88, 100), isNew: false);
|
|
|
|
// Correct back to original data
|
|
w.Update(new TBar(time.AddMinutes(3), 103, 108, 98, 105, 100), isNew: false);
|
|
var restored = w.Last.Value;
|
|
|
|
Assert.Equal(original, restored, 1e-10);
|
|
}
|
|
}
|
|
|
|
public sealed class WillrResetTests
|
|
{
|
|
[Fact]
|
|
public void Reset_ClearsState()
|
|
{
|
|
var w = new Willr(period: 5);
|
|
var time = DateTime.UtcNow;
|
|
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
w.Update(new TBar(time.AddMinutes(i), 100 + i, 105 + i, 95 + i, 102 + i, 100), isNew: true);
|
|
}
|
|
|
|
Assert.True(w.IsHot);
|
|
|
|
w.Reset();
|
|
|
|
Assert.False(w.IsHot);
|
|
Assert.Equal(default, w.Last);
|
|
}
|
|
|
|
[Fact]
|
|
public void Reset_AllowsReuse()
|
|
{
|
|
var w = new Willr(period: 5);
|
|
var time = DateTime.UtcNow;
|
|
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
w.Update(new TBar(time.AddMinutes(i), 100 + i, 105 + i, 95 + i, 102 + i, 100), isNew: true);
|
|
}
|
|
|
|
w.Reset();
|
|
|
|
// Should be reusable after reset
|
|
var result = w.Update(new TBar(time, 100, 105, 95, 100, 100), isNew: true);
|
|
Assert.True(double.IsFinite(result.Value));
|
|
Assert.False(w.IsHot);
|
|
}
|
|
}
|
|
|
|
public sealed class WillrRobustnessTests
|
|
{
|
|
[Fact]
|
|
public void NaN_Uses_LastValid()
|
|
{
|
|
var w = new Willr(period: 5);
|
|
var time = DateTime.UtcNow;
|
|
|
|
// Feed valid data
|
|
for (int i = 0; i < 5; i++)
|
|
{
|
|
w.Update(new TBar(time.AddMinutes(i), 100, 105, 95, 100, 100), isNew: true);
|
|
}
|
|
|
|
_ = w.Last.Value;
|
|
|
|
// Feed NaN bar
|
|
w.Update(new TBar(time.AddMinutes(5), double.NaN, double.NaN, double.NaN, double.NaN, 100), isNew: true);
|
|
|
|
Assert.True(double.IsFinite(w.Last.Value));
|
|
}
|
|
|
|
[Fact]
|
|
public void Infinity_Uses_LastValid()
|
|
{
|
|
var w = new Willr(period: 5);
|
|
var time = DateTime.UtcNow;
|
|
|
|
for (int i = 0; i < 5; i++)
|
|
{
|
|
w.Update(new TBar(time.AddMinutes(i), 100, 105, 95, 100, 100), isNew: true);
|
|
}
|
|
|
|
// Feed Infinity bar
|
|
w.Update(new TBar(time.AddMinutes(5), double.PositiveInfinity, double.PositiveInfinity,
|
|
double.NegativeInfinity, double.PositiveInfinity, 100), isNew: true);
|
|
|
|
Assert.True(double.IsFinite(w.Last.Value));
|
|
}
|
|
|
|
[Fact]
|
|
public void AllNaN_Returns_NaN()
|
|
{
|
|
var w = new Willr(period: 5);
|
|
var time = DateTime.UtcNow;
|
|
|
|
// First data is NaN — no last-valid to substitute
|
|
var result = w.Update(new TBar(time, double.NaN, double.NaN, double.NaN, double.NaN, 100), isNew: true);
|
|
Assert.True(double.IsNaN(result.Value));
|
|
}
|
|
}
|
|
|
|
public sealed class WillrBatchTests
|
|
{
|
|
private static TBarSeries GenerateSeries(int count, int seed = 42)
|
|
{
|
|
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: seed);
|
|
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
|
}
|
|
|
|
[Fact]
|
|
public void Batch_TBarSeries_ProducesOutput()
|
|
{
|
|
var bars = GenerateSeries(100);
|
|
var result = Willr.Batch(bars, period: 14);
|
|
|
|
Assert.Equal(100, result.Count);
|
|
Assert.True(double.IsFinite(result[^1].Value));
|
|
}
|
|
|
|
[Fact]
|
|
public void Calculate_Returns_ResultsAndIndicator()
|
|
{
|
|
var bars = GenerateSeries(100);
|
|
var (results, indicator) = Willr.Calculate(bars, period: 14);
|
|
|
|
Assert.Equal(100, results.Count);
|
|
Assert.True(indicator.IsHot);
|
|
Assert.True(double.IsFinite(indicator.Last.Value));
|
|
}
|
|
|
|
[Fact]
|
|
public void Streaming_Matches_Batch()
|
|
{
|
|
var bars = GenerateSeries(200);
|
|
const int period = 14;
|
|
|
|
var w = new Willr(period);
|
|
for (int i = 0; i < bars.Count; i++)
|
|
{
|
|
w.Update(bars[i]);
|
|
}
|
|
|
|
var batch = Willr.Batch(bars, period);
|
|
|
|
Assert.Equal(w.Last.Value, batch[^1].Value, 1e-6);
|
|
}
|
|
|
|
[Fact]
|
|
public void Batch_Span_Empty_NoException()
|
|
{
|
|
var output = Array.Empty<double>();
|
|
Willr.Batch(ReadOnlySpan<double>.Empty, ReadOnlySpan<double>.Empty,
|
|
ReadOnlySpan<double>.Empty, output.AsSpan(), 14);
|
|
Assert.Empty(output);
|
|
}
|
|
|
|
[Fact]
|
|
public void Batch_Span_InvalidPeriod_Throws()
|
|
{
|
|
var ex = Assert.Throws<ArgumentException>(() =>
|
|
Willr.Batch(new double[10], new double[10], new double[10], new double[10], 0));
|
|
Assert.Equal("period", ex.ParamName);
|
|
}
|
|
|
|
[Fact]
|
|
public void Batch_Span_MismatchedLengths_Throws()
|
|
{
|
|
var ex = Assert.Throws<ArgumentException>(() =>
|
|
Willr.Batch(new double[10], new double[5], new double[10], new double[10], 14));
|
|
Assert.Equal("high", ex.ParamName);
|
|
}
|
|
|
|
[Fact]
|
|
public void Batch_Span_OutputTooSmall_Throws()
|
|
{
|
|
var ex = Assert.Throws<ArgumentException>(() =>
|
|
Willr.Batch(new double[10], new double[10], new double[10], new double[5], 14));
|
|
Assert.Equal("output", ex.ParamName);
|
|
}
|
|
|
|
[Fact]
|
|
public void Update_TBarSeries_ProducesOutput()
|
|
{
|
|
var bars = GenerateSeries(100);
|
|
var w = new Willr(14);
|
|
var result = w.Update(bars);
|
|
|
|
Assert.Equal(100, result.Count);
|
|
Assert.True(w.IsHot);
|
|
}
|
|
|
|
[Fact]
|
|
public void Batch_NullSource_ReturnsEmpty()
|
|
{
|
|
var result = Willr.Batch(null!, 14);
|
|
Assert.Empty(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void Batch_EmptySource_ReturnsEmpty()
|
|
{
|
|
var result = Willr.Batch(new TBarSeries(), 14);
|
|
Assert.Empty(result);
|
|
}
|
|
}
|
|
|
|
public sealed class WillrEventTests
|
|
{
|
|
[Fact]
|
|
public void Pub_Fires_OnUpdate()
|
|
{
|
|
var w = new Willr(period: 5);
|
|
var eventRaised = false;
|
|
|
|
w.Pub += (object? _, in TValueEventArgs e) => { eventRaised = true; };
|
|
|
|
w.Update(new TBar(DateTime.UtcNow, 100, 105, 95, 100, 100), isNew: true);
|
|
Assert.True(eventRaised);
|
|
}
|
|
|
|
[Fact]
|
|
public void Chaining_Works()
|
|
{
|
|
var bars = new TBarSeries();
|
|
var w = new Willr(bars, period: 5);
|
|
|
|
TValue? lastValue = null;
|
|
w.Pub += (object? _, in TValueEventArgs e) => { lastValue = e.Value; };
|
|
|
|
var time = DateTime.UtcNow;
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
bars.Add(new TBar(time.AddMinutes(i), 100 + i, 105 + i, 95 + i, 102 + i, 100));
|
|
}
|
|
|
|
Assert.NotNull(lastValue);
|
|
Assert.True(double.IsFinite(lastValue.Value.Value));
|
|
}
|
|
}
|
|
|
|
public sealed class WillrPrimeTests
|
|
{
|
|
[Fact]
|
|
public void Prime_TBarSeries_SetsState()
|
|
{
|
|
var bars = new TBarSeries();
|
|
var time = DateTime.UtcNow;
|
|
|
|
for (int i = 0; i < 20; i++)
|
|
{
|
|
bars.Add(new TBar(time.AddMinutes(i), 100 + i, 105 + i, 95 + i, 102 + i, 100));
|
|
}
|
|
|
|
var w = new Willr(period: 5);
|
|
w.Prime(bars);
|
|
|
|
Assert.True(w.IsHot);
|
|
Assert.True(double.IsFinite(w.Last.Value));
|
|
}
|
|
|
|
[Fact]
|
|
public void Prime_Span_SetsState()
|
|
{
|
|
var data = new double[50];
|
|
for (int i = 0; i < 50; i++)
|
|
{
|
|
data[i] = 100.0 + i;
|
|
}
|
|
|
|
var w = new Willr(period: 5);
|
|
w.Prime(data.AsSpan());
|
|
|
|
Assert.True(w.IsHot);
|
|
Assert.True(double.IsFinite(w.Last.Value));
|
|
}
|
|
|
|
[Fact]
|
|
public void Prime_EmptySeries_NoError()
|
|
{
|
|
var w = new Willr(period: 5);
|
|
w.Prime(new TBarSeries());
|
|
|
|
Assert.False(w.IsHot);
|
|
}
|
|
|
|
[Fact]
|
|
public void Prime_EmptySpan_NoError()
|
|
{
|
|
var w = new Willr(period: 5);
|
|
w.Prime(ReadOnlySpan<double>.Empty);
|
|
|
|
Assert.False(w.IsHot);
|
|
}
|
|
}
|
|
|
|
public sealed class WillrConsistencyTests
|
|
{
|
|
private static TBarSeries GenerateSeries(int count, int seed = 42)
|
|
{
|
|
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: seed);
|
|
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
|
}
|
|
|
|
[Fact]
|
|
public void Span_Matches_TBarSeries()
|
|
{
|
|
var bars = GenerateSeries(200);
|
|
const int period = 14;
|
|
|
|
var batchResult = Willr.Batch(bars, period);
|
|
|
|
var output = new double[bars.Count];
|
|
Willr.Batch(bars.HighValues, bars.LowValues, bars.CloseValues, output.AsSpan(), period);
|
|
|
|
for (int i = 0; i < bars.Count; i++)
|
|
{
|
|
Assert.Equal(batchResult.Values[i], output[i], 12);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Different_Periods_Produce_Different_Results()
|
|
{
|
|
var bars = GenerateSeries(100);
|
|
|
|
var r5 = Willr.Batch(bars, period: 5);
|
|
var r20 = Willr.Batch(bars, period: 20);
|
|
|
|
bool anyDifferent = false;
|
|
for (int i = 20; i < 100; i++)
|
|
{
|
|
if (Math.Abs(r5.Values[i] - r20.Values[i]) > 0.01)
|
|
{
|
|
anyDifferent = true;
|
|
break;
|
|
}
|
|
}
|
|
Assert.True(anyDifferent);
|
|
}
|
|
|
|
[Fact]
|
|
public void Deterministic_Across_Runs()
|
|
{
|
|
var bars = GenerateSeries(200, seed: 99);
|
|
const int period = 14;
|
|
|
|
var r1 = Willr.Batch(bars, period);
|
|
var r2 = Willr.Batch(bars, period);
|
|
|
|
for (int i = 0; i < bars.Count; i++)
|
|
{
|
|
Assert.Equal(r1.Values[i], r2.Values[i], 15);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void WillR_Is_Inverse_Stoch()
|
|
{
|
|
var bars = GenerateSeries(200);
|
|
const int period = 14;
|
|
|
|
var willr = Willr.Batch(bars, period);
|
|
var (stochK, _) = Stoch.Batch(bars, kLength: period);
|
|
|
|
// WillR = -(100 - Stoch%K) = Stoch%K - 100
|
|
// But only when range>0 (when range=0, Stoch returns 0, WillR returns -50)
|
|
for (int i = period; i < bars.Count; i++)
|
|
{
|
|
double stochVal = stochK.Values[i];
|
|
double willrVal = willr.Values[i];
|
|
|
|
if (Math.Abs(stochVal) > 1e-10 || Math.Abs(willrVal + 50.0) > 1e-10)
|
|
{
|
|
// Only compare when not at the degenerate range=0 case
|
|
Assert.Equal(stochVal - 100.0, willrVal, 1e-9);
|
|
}
|
|
}
|
|
}
|
|
}
|