diff --git a/AGENTS.md b/AGENTS.md index 90088863..e9cacf01 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -183,7 +183,7 @@ public TValue Update(TValue input, bool isNew = true) * **Format**: Markdown. * **Content**: Title, Description, Parameters, Formula (LaTeX), C# Usage Examples. -* **Index**: Add the new indicator to the category index (e.g., `lib/trends/_index.md`). +* **Index**: Add the new indicator to the category index (e.g., `lib/trends/_index.md`) AND the main index (`lib/_index.md`). * **Linting**: Ensure that markdownlint shows no issues for the file. * **MD030:** Ensure exactly one space after list markers. * **MD032:** Ensure lists are surrounded by blank lines. @@ -209,7 +209,7 @@ When creating a new indicator, you are **DONE** only when: * [ ] Static `Calculate(Span)` is implemented. * [ ] Unit tests pass (including edge cases). * [ ] Validation tests pass against external libs. -* [ ] Documentation is complete and linked in `_index.md`. +* [ ] Documentation is complete and linked in both `_index.md` files. * [ ] Quantower adapter and tests are implemented. * [ ] CodeRabbit review issues are resolved. diff --git a/MODELS.md b/MODELS.md new file mode 100644 index 00000000..ab3a17f4 --- /dev/null +++ b/MODELS.md @@ -0,0 +1,113 @@ +# Recommended Test Pattern for Indicators + +This document outlines the standard set of unit tests that every indicator in QuanTAlib should implement to ensure correctness, consistency, and robustness. + +## 1. Standard Unit Tests (`[Name].Tests.cs`) + +These tests verify the internal logic, state management, and API contract of the indicator. + +### Constructor & Validation + +- **`Constructor_ValidatesInput`**: Verify that invalid parameters (e.g., `period <= 0`) throw `ArgumentException`. +- **`Constructor_ValidatesOptionalArgs`**: If applicable, verify other parameters (e.g., `alpha`, `sigma`). + +### Basic Functionality + +- **`Calc_ReturnsValue`**: Verify `Update` returns a valid `TValue` and updates the `Last` property. +- **`FirstValue_ReturnsExpected`**: Verify the first output value (often the input itself for averages). +- **`Properties_Accessible`**: Verify `Last`, `IsHot`, `Name`, etc., are accessible and initialized correctly. + +### State Management & Bar Correction + +- **`Calc_IsNew_AcceptsParameter`**: Verify that `isNew: true` advances the state. +- **`Calc_IsNew_False_UpdatesValue`**: Verify that `isNew: false` updates the current value without advancing state (intra-bar update). +- **`IterativeCorrections_RestoreToOriginalState`**: Critical test. + 1. Feed $N$ values. + 2. Remember state. + 3. Feed $M$ updates with `isNew: false`. + 4. Feed the original $N$-th value again with `isNew: false`. + 5. Verify state matches the remembered state. +- **`Reset_ClearsState`**: Verify `Reset()` clears all internal state and the indicator behaves like a new instance. + +### Warmup & Convergence + +- **`IsHot_BecomesTrueWhenBufferFull`**: Verify `IsHot` becomes true after the expected number of periods. +- **`IsHot_IsPeriodDependent`**: If applicable, verify warmup time scales with period. + +### Robustness (NaN/Infinity) + +- **`NaN_Input_UsesLastValidValue`**: Verify that `NaN` input does not crash and typically carries forward the last valid value. +- **`Infinity_Input_UsesLastValidValue`**: Verify handling of `PositiveInfinity` and `NegativeInfinity`. +- **`MultipleNaN_ContinuesWithLastValid`**: Verify behavior with consecutive invalid inputs. +- **`BatchCalc_HandlesNaN`**: Verify batch processing handles `NaN` correctly. + +### Consistency + +- **`BatchCalc_MatchesIterativeCalc`**: Verify that `Update(TSeries)` produces the same results as a loop of `Update(TValue)`. +- **`AllModes_ProduceSameResult`**: **Crucial**. Verify that all 4 usage modes produce identical results: + 1. **Batch**: `Indicator.Calculate(TSeries)` + 2. **Span**: `Indicator.Calculate(ReadOnlySpan, Span)` + 3. **Streaming**: `new Indicator().Update(TValue)` + 4. **Eventing**: `new Indicator(source).Update()` + +### Span API (High Performance) + +- **`SpanCalc_ValidatesInput`**: Verify input/output buffer length checks. +- **`SpanCalc_MatchesTSeriesCalc`**: Verify Span API output matches TSeries API output. +- **`SpanCalc_ZeroAllocation`**: Verify the method runs without obvious errors on large datasets (allocation verified via benchmarks, but this ensures no OOM or stack overflow). +- **`SpanCalc_HandlesNaN`**: Verify Span API handles invalid inputs safely. + +## 2. Validation Tests (`[Name].Validation.Tests.cs`) + +These tests compare the indicator's output against established external libraries to ensure mathematical accuracy. + +- **Compare against Skender.Stock.Indicators**: Primary validation target. +- **Compare against TA-Lib**: Secondary validation target. +- **Compare against Python (pandas-ta/talib)**: If C# libs are unavailable. +- **Tolerance**: Typically `1e-6` to `1e-9`. + +## 3. Example Test Template + +```csharp +[Fact] +public void AllModes_ProduceSameResult() +{ + // Arrange + int period = 10; + var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123); + var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; + + // 1. Batch Mode + var batchSeries = MyIndicator.Calculate(series, period); + double expected = batchSeries.Last.Value; + + // 2. Span Mode + var tValues = series.Values.ToArray(); + var spanInput = new ReadOnlySpan(tValues); + var spanOutput = new double[tValues.Length]; + MyIndicator.Calculate(spanInput, spanOutput, period); + double spanResult = spanOutput[^1]; + + // 3. Streaming Mode + var streamingInd = new MyIndicator(period); + for (int i = 0; i < series.Count; i++) + { + streamingInd.Update(series[i]); + } + double streamingResult = streamingInd.Last.Value; + + // 4. Eventing Mode + var pubSource = new TSeries(); + var eventingInd = new MyIndicator(pubSource, period); + for (int i = 0; i < series.Count; i++) + { + pubSource.Add(series[i]); + } + double eventingResult = eventingInd.Last.Value; + + // Assert + Assert.Equal(expected, spanResult, precision: 9); + Assert.Equal(expected, streamingResult, precision: 9); + Assert.Equal(expected, eventingResult, precision: 9); +} diff --git a/lib/_index.md b/lib/_index.md index 3f24e181..7c193e48 100644 --- a/lib/_index.md +++ b/lib/_index.md @@ -15,7 +15,7 @@ | ALLIGATOR | Williams Alligator | Trends | | [ALMA](trends/alma/Alma.md) | Arnaud Legoux MA | Trends | | AMAT | Archer Moving Averages Trends | Trends | -| AO | Awesome Oscillator | Momentum | +| [AO](momentum/ao/Ao.md) | Awesome Oscillator | Momentum | | AOBV | Archer On-Balance Volume | Volume | | APCHANNEL | Andrews' Pitchfork | Channels | | APO | Absolute Price Oscillator | Momentum | diff --git a/lib/momentum/adx/Adx.Tests.cs b/lib/momentum/adx/Adx.Tests.cs index ce9f1907..a25b7e5b 100644 --- a/lib/momentum/adx/Adx.Tests.cs +++ b/lib/momentum/adx/Adx.Tests.cs @@ -1,111 +1,150 @@ +using System; +using System.Collections.Generic; using Xunit; -namespace QuanTAlib.Tests; +namespace QuanTAlib; public class AdxTests { - private readonly GBM _gbm = new(); + [Fact] + public void BasicCalculation_DoesNotCrash() + { + var adx = new Adx(14); + var gbm = new GBM(); + var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < bars.Count; i++) + { + adx.Update(bars[i]); + } + + Assert.True(double.IsFinite(adx.Last.Value)); + } [Fact] - public void Constructor_ThrowsArgumentException_WhenPeriodIsInvalid() + public void IsNew_Consistency() + { + var adx = new Adx(14); + var gbm = new GBM(); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // Feed first 99 + for (int i = 0; i < 99; i++) + { + adx.Update(bars[i]); + } + + // Update with 100th point (isNew=true) + adx.Update(bars[99], true); + + // Update with modified 100th point (isNew=false) + var modifiedBar = new TBar(bars[99].Time, bars[99].Open, bars[99].High + 1.0, bars[99].Low - 1.0, bars[99].Close, bars[99].Volume); + var val2 = adx.Update(modifiedBar, false); + + // Create new instance and feed up to modified + var adx2 = new Adx(14); + for (int i = 0; i < 99; i++) + { + adx2.Update(bars[i]); + } + var val3 = adx2.Update(modifiedBar, true); + + Assert.Equal(val3.Value, val2.Value, 1e-9); + Assert.Equal(adx2.DiPlus.Value, adx.DiPlus.Value, 1e-9); + Assert.Equal(adx2.DiMinus.Value, adx.DiMinus.Value, 1e-9); + } + + [Fact] + public void Reset_Works() + { + var adx = new Adx(14); + var gbm = new GBM(); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < bars.Count; i++) + { + adx.Update(bars[i]); + } + + adx.Reset(); + Assert.Equal(0, adx.Last.Value); + Assert.False(adx.IsHot); + + // Feed again + for (int i = 0; i < bars.Count; i++) + { + adx.Update(bars[i]); + } + + Assert.True(double.IsFinite(adx.Last.Value)); + } + + [Fact] + public void TBarSeries_Update_Matches_Streaming() + { + var adx = new Adx(14); + var gbm = new GBM(); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + var streamingResults = new List(); + for (int i = 0; i < bars.Count; i++) + { + streamingResults.Add(adx.Update(bars[i]).Value); + } + + var adx2 = new Adx(14); + var seriesResults = adx2.Update(bars); + + Assert.Equal(streamingResults.Count, seriesResults.Count); + for (int i = 0; i < seriesResults.Count; i++) + { + Assert.Equal(streamingResults[i], seriesResults.Values[i], 1e-9); + } + } + + [Fact] + public void StaticCalculate_Matches_Streaming() + { + var gbm = new GBM(); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + var adx = new Adx(14); + var streamingResults = new List(); + for (int i = 0; i < bars.Count; i++) + { + streamingResults.Add(adx.Update(bars[i]).Value); + } + + var staticResults = Adx.Calculate(bars, 14); + + Assert.Equal(streamingResults.Count, staticResults.Count); + for (int i = 0; i < staticResults.Count; i++) + { + Assert.Equal(streamingResults[i], staticResults.Values[i], 1e-9); + } + } + + [Fact] + public void Chainability_Works() + { + var adx = new Adx(14); + var gbm = new GBM(); + var bars = gbm.Fetch(10, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // Test TBarSeries chain + var result = adx.Update(bars); + Assert.NotNull(result); + Assert.IsType(result); + + // Test TBar chain (returns TValue) + var result2 = adx.Update(bars[0]); + Assert.IsType(result2); + } + + [Fact] + public void Constructor_InvalidParameters_ThrowsArgumentException() { Assert.Throws(() => new Adx(0)); Assert.Throws(() => new Adx(-1)); } - - [Fact] - public void Update_ReturnsValidValues_WhenInputIsValid() - { - var adx = new Adx(14); - var bars = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); - - foreach (var bar in bars) - { - var result = adx.Update(bar); - Assert.True(double.IsFinite(result.Value)); - } - } - - [Fact] - public void Update_HandlesIsNewCorrectly() - { - var adx = new Adx(14); - // We need enough bars to warm up ADX (2 * Period) - int count = 2 * 14 + 5; - var bars = _gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); - - // Feed all but last bar - for (int i = 0; i < count - 1; i++) - { - adx.Update(bars[i]); - } - - // Update with last bar (isNew=true) - var result1 = adx.Update(bars[count - 1], true); - - // Update with modified last bar (isNew=false) - var modifiedBar = new TBar(bars[count - 1].Time, bars[count - 1].Open, bars[count - 1].High + 1, bars[count - 1].Low - 1, bars[count - 1].Close, bars[count - 1].Volume); - var result2 = adx.Update(modifiedBar, false); - - // The result should change because High/Low changed, affecting TR and DM - Assert.NotEqual(result1.Value, result2.Value); - } - - [Fact] - public void Reset_ResetsState() - { - var adx = new Adx(14); - var bars = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); // Increased to 100 - - foreach (var bar in bars) - { - adx.Update(bar); - } - - Assert.True(adx.IsHot); - adx.Reset(); - Assert.False(adx.IsHot); - Assert.Equal(0, adx.Last.Value); - } - - [Fact] - public void IsHot_BecomesTrue_AfterWarmup() - { - var adx = new Adx(14); - var bars = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); - - int i = 0; - for (; i < bars.Count; i++) - { - adx.Update(bars[i]); - if (adx.IsHot) break; - } - - Assert.True(i < bars.Count); - Assert.True(adx.IsHot); - } - - [Fact] - public void Update_HandlesNaN_Gracefully() - { - var adx = new Adx(14); - var bar = new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, 0); - - var result = adx.Update(bar); - - // Should not throw and return finite value (likely 0 or last valid) - // Since it's the first value, it might be 0. - Assert.True(double.IsFinite(result.Value)); - } - - [Fact] - public void Update_TValue_ReturnsValidResult() - { - var adx = new Adx(14); - var val = new TValue(DateTime.UtcNow, 100); - - var result = adx.Update(val); - - Assert.True(double.IsFinite(result.Value)); - } } diff --git a/lib/momentum/adx/Adx.Validation.Tests.cs b/lib/momentum/adx/Adx.Validation.Tests.cs index bf03f7ce..c7836cc0 100644 --- a/lib/momentum/adx/Adx.Validation.Tests.cs +++ b/lib/momentum/adx/Adx.Validation.Tests.cs @@ -8,7 +8,7 @@ using QuanTAlib.Tests; namespace QuanTAlib; -public class AdxValidationTests : IDisposable +public sealed class AdxValidationTests : IDisposable { private readonly ValidationTestData _data; @@ -19,16 +19,7 @@ public class AdxValidationTests : IDisposable public void Dispose() { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - _data.Dispose(); - } + _data.Dispose(); } [Fact] diff --git a/lib/momentum/ao/Ao.Tests.cs b/lib/momentum/ao/Ao.Tests.cs index e464bf11..b2de29ca 100644 --- a/lib/momentum/ao/Ao.Tests.cs +++ b/lib/momentum/ao/Ao.Tests.cs @@ -1,121 +1,149 @@ +using System; +using System.Collections.Generic; using Xunit; -namespace QuanTAlib.Tests; +namespace QuanTAlib; public class AoTests { [Fact] - public void Constructor_ValidatesParameters() + public void BasicCalculation_DoesNotCrash() + { + var ao = new Ao(5, 34); + var gbm = new GBM(); + var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < bars.Count; i++) + { + ao.Update(bars[i]); + } + + Assert.True(double.IsFinite(ao.Last.Value)); + } + + [Fact] + public void IsNew_Consistency() + { + var ao = new Ao(5, 34); + var gbm = new GBM(); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // Feed first 99 + for (int i = 0; i < 99; i++) + { + ao.Update(bars[i]); + } + + // Update with 100th point (isNew=true) + ao.Update(bars[99], true); + + // Update with modified 100th point (isNew=false) + var modifiedBar = new TBar(bars[99].Time, bars[99].Open, bars[99].High + 1.0, bars[99].Low - 1.0, bars[99].Close, bars[99].Volume); + var val2 = ao.Update(modifiedBar, false); + + // Create new instance and feed up to modified + var ao2 = new Ao(5, 34); + for (int i = 0; i < 99; i++) + { + ao2.Update(bars[i]); + } + var val3 = ao2.Update(modifiedBar, true); + + Assert.Equal(val3.Value, val2.Value, 1e-9); + } + + [Fact] + public void Reset_Works() + { + var ao = new Ao(5, 34); + var gbm = new GBM(); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < bars.Count; i++) + { + ao.Update(bars[i]); + } + + ao.Reset(); + Assert.Equal(0, ao.Last.Value); + Assert.False(ao.IsHot); + + // Feed again + for (int i = 0; i < bars.Count; i++) + { + ao.Update(bars[i]); + } + + Assert.True(double.IsFinite(ao.Last.Value)); + } + + [Fact] + public void TBarSeries_Update_Matches_Streaming() + { + var ao = new Ao(5, 34); + var gbm = new GBM(); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + var streamingResults = new List(); + for (int i = 0; i < bars.Count; i++) + { + streamingResults.Add(ao.Update(bars[i]).Value); + } + + var ao2 = new Ao(5, 34); + var seriesResults = ao2.Update(bars); + + Assert.Equal(streamingResults.Count, seriesResults.Count); + for (int i = 0; i < seriesResults.Count; i++) + { + Assert.Equal(streamingResults[i], seriesResults.Values[i], 1e-9); + } + } + + [Fact] + public void StaticCalculate_Matches_Streaming() + { + var gbm = new GBM(); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + var ao = new Ao(5, 34); + var streamingResults = new List(); + for (int i = 0; i < bars.Count; i++) + { + streamingResults.Add(ao.Update(bars[i]).Value); + } + + var staticResults = Ao.Calculate(bars, 5, 34); + + Assert.Equal(streamingResults.Count, staticResults.Count); + for (int i = 0; i < staticResults.Count; i++) + { + Assert.Equal(streamingResults[i], staticResults.Values[i], 1e-9); + } + } + + [Fact] + public void Chainability_Works() + { + var ao = new Ao(5, 34); + var gbm = new GBM(); + var bars = gbm.Fetch(10, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // Test TBarSeries chain + var result = ao.Update(bars); + Assert.NotNull(result); + Assert.IsType(result); + + // Test TBar chain (returns TValue) + var result2 = ao.Update(bars[0]); + Assert.IsType(result2); + } + + [Fact] + public void Constructor_InvalidParameters_ThrowsArgumentException() { Assert.Throws(() => new Ao(0, 34)); Assert.Throws(() => new Ao(5, 0)); Assert.Throws(() => new Ao(34, 5)); // Fast >= Slow } - - [Fact] - public void IsHot_BecomesTrueAfterSlowPeriod() - { - var ao = new Ao(2, 5); - - // Add 4 values - for (int i = 0; i < 4; i++) - { - ao.Update(new TBar(DateTime.UtcNow, 10, 10, 10, 10, 100)); - Assert.False(ao.IsHot); - } - - // Add 5th value - ao.Update(new TBar(DateTime.UtcNow, 10, 10, 10, 10, 100)); - Assert.True(ao.IsHot); - } - - [Fact] - public void Calculation_Correctness() - { - // AO = SMA(Median, 5) - SMA(Median, 34) - // Let's use smaller periods for testing: 2 and 4 - var ao = new Ao(2, 4); - - // Median prices: 10, 20, 30, 40, 50 - // SMA2: -, 15, 25, 35, 45 - // SMA4: -, -, -, 25, 35 - // AO: -, -, -, 10, 10 - - var data = new[] { 10.0, 20.0, 30.0, 40.0, 50.0 }; - // Sma returns average of available data. - // SMA2(10) = 10 - // SMA2(10, 20) = 15 - // SMA2(20, 30) = 25 - // SMA2(30, 40) = 35 - // SMA2(40, 50) = 45 - - // SMA4(10) = 10 - // SMA4(10, 20) = 15 - // SMA4(10, 20, 30) = 20 - // SMA4(10, 20, 30, 40) = 25 - // SMA4(20, 30, 40, 50) = 35 - - // AO: - // 1: 10 - 10 = 0 - // 2: 15 - 15 = 0 - // 3: 25 - 20 = 5 - // 4: 35 - 25 = 10 - // 5: 45 - 35 = 10 - - for (int i = 0; i < data.Length; i++) - { - var bar = new TBar(DateTime.UtcNow, data[i], data[i], data[i], data[i], 100); - var result = ao.Update(bar); - - if (i == 2) Assert.Equal(5.0, result.Value); - if (i >= 3) Assert.Equal(10.0, result.Value); - } - } - - [Fact] - public void Update_WithIsNewFalse_UpdatesLastValue() - { - var ao = new Ao(2, 4); - - // 1. Add 10 - ao.Update(new TBar(DateTime.UtcNow, 10, 10, 10, 10, 100)); - // SMA2=10, SMA4=10, AO=0 - - // 2. Add 20 - ao.Update(new TBar(DateTime.UtcNow, 20, 20, 20, 20, 100)); - // SMA2=15, SMA4=15, AO=0 - - // 3. Update last with 30 (instead of 20) - var result = ao.Update(new TBar(DateTime.UtcNow, 30, 30, 30, 30, 100), isNew: false); - - // SMA2(10, 30) = 20 - // SMA4(10, 30) = 20 - // AO = 0 - Assert.Equal(0.0, result.Value); - - // 4. Add 40 - result = ao.Update(new TBar(DateTime.UtcNow, 40, 40, 40, 40, 100)); - // SMA2(30, 40) = 35 - // SMA4(10, 30, 40) = 26.666... - // AO = 35 - 26.666... = 8.333... - - Assert.True(result.Value > 0); - } - - [Fact] - public void Reset_ClearsState() - { - var ao = new Ao(2, 4); - ao.Update(new TBar(DateTime.UtcNow, 10, 10, 10, 10, 100)); - ao.Update(new TBar(DateTime.UtcNow, 20, 20, 20, 20, 100)); - - ao.Reset(); - - Assert.False(ao.IsHot); - Assert.Equal(0, ao.Last.Value); - - // Should behave like new - ao.Update(new TBar(DateTime.UtcNow, 10, 10, 10, 10, 100)); - Assert.Equal(0, ao.Last.Value); - } } diff --git a/lib/momentum/cfb/Cfb.Tests.cs b/lib/momentum/cfb/Cfb.Tests.cs index 3811076f..494efa61 100644 --- a/lib/momentum/cfb/Cfb.Tests.cs +++ b/lib/momentum/cfb/Cfb.Tests.cs @@ -155,4 +155,46 @@ public class CfbTests Assert.Equal(streamingResults[i], spanResults[i]); } } + + [Fact] + public void Reset_Works() + { + var cfb = new Cfb(); + var gbm = new GBM(); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < bars.Count; i++) + { + cfb.Update(new TValue(bars.Close.Times[i], bars.Close.Values[i])); + } + + Assert.True(cfb.Last.Value >= 1.0); + + cfb.Reset(); + + Assert.Equal(0, cfb.Last.Value); + Assert.Equal(0, cfb.Last.Time); + + // Feed again + for (int i = 0; i < bars.Count; i++) + { + cfb.Update(new TValue(bars.Close.Times[i], bars.Close.Values[i])); + } + + Assert.True(cfb.Last.Value >= 1.0); + } + + [Fact] + public void Chainability_Works() + { + var cfb = new Cfb(); + var cfb2 = new Cfb(cfb); + + for (int i = 0; i < 100; i++) + { + cfb.Update(new TValue(DateTime.UtcNow, i)); + } + + Assert.True(cfb2.Last.Value > 0); + } } diff --git a/lib/momentum/cfb/Cfb.cs b/lib/momentum/cfb/Cfb.cs index 3354be03..4f9143e6 100644 --- a/lib/momentum/cfb/Cfb.cs +++ b/lib/momentum/cfb/Cfb.cs @@ -87,6 +87,20 @@ public sealed class Cfb : ITValuePublisher source.Pub += (item) => Update(item); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Reset() + { + _prices.Clear(); + _volatility.Clear(); + Array.Clear(_runningSums); + Array.Clear(_p_runningSums); + _state = default; + _state.PrevCfb = 1.0; + _p_state = default; + _p_state.PrevCfb = 1.0; + Last = default; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public TValue Update(TValue input, bool isNew = true) { diff --git a/lib/momentum/dmx/Dmx.Tests.cs b/lib/momentum/dmx/Dmx.Tests.cs index 684a7d40..ed773b67 100644 --- a/lib/momentum/dmx/Dmx.Tests.cs +++ b/lib/momentum/dmx/Dmx.Tests.cs @@ -110,4 +110,42 @@ public class DmxTests Assert.Equal(0, result.Value); } + + [Fact] + public void StaticCalculate_Matches_Streaming() + { + var gbm = new GBM(); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + var dmx = new Dmx(14); + var streamingResults = new List(); + for (int i = 0; i < bars.Count; i++) + { + streamingResults.Add(dmx.Update(bars[i]).Value); + } + + var staticResults = Dmx.Calculate(bars, 14); + + Assert.Equal(streamingResults.Count, staticResults.Count); + for (int i = 0; i < streamingResults.Count; i++) + { + Assert.Equal(streamingResults[i], staticResults.Values[i], 1e-9); + } + } + + [Fact] + public void Chainability_Works() + { + var dmx = new Dmx(14); + var sma = new Sma(dmx, 10); + var gbm = new GBM(); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < bars.Count; i++) + { + dmx.Update(bars[i]); + } + + Assert.True(sma.Last.Value != 0); + } } diff --git a/lib/momentum/dmx/Dmx.cs b/lib/momentum/dmx/Dmx.cs index 897caa04..a56686c0 100644 --- a/lib/momentum/dmx/Dmx.cs +++ b/lib/momentum/dmx/Dmx.cs @@ -129,4 +129,10 @@ public sealed class Dmx : ITValuePublisher return new TSeries(t, v); } + + public static TSeries Calculate(TBarSeries source, int period = 14) + { + var dmx = new Dmx(period); + return dmx.Update(source); + } } diff --git a/lib/momentum/rsx/Rsx.Tests.cs b/lib/momentum/rsx/Rsx.Tests.cs index 05327474..4b23ad7c 100644 --- a/lib/momentum/rsx/Rsx.Tests.cs +++ b/lib/momentum/rsx/Rsx.Tests.cs @@ -20,7 +20,7 @@ public class RsxTests } [Fact] - public void Update_ValidInput_ReturnsValidRsx() + public void BasicCalculation_DoesNotCrash() { var rsx = new Rsx(14); var result = rsx.Update(new TValue(DateTime.UtcNow, 100)); @@ -40,7 +40,7 @@ public class RsxTests } [Fact] - public void Update_IsNew_Consistency() + public void IsNew_Consistency() { var rsx = new Rsx(14); var time = DateTime.UtcNow; @@ -52,15 +52,13 @@ public class RsxTests rsx.Update(new TValue(time, 105), false); // Update with isNew=false (same time, original value) - should match val1 if state rollback works - // Note: RSX is highly sensitive to path, so exact match might be tricky if intermediate states drift, - // but for a single step rollback it should be very close. var val3 = rsx.Update(new TValue(time, 100), false); Assert.Equal(val1.Value, val3.Value, 1e-9); } [Fact] - public void Calculate_Span_Matches_Update() + public void StaticCalculate_Matches_Streaming() { int period = 14; int count = 100; @@ -68,7 +66,35 @@ public class RsxTests var series = bars.Close; var rsx = new Rsx(period); - var resultSeries = rsx.Update(series); + var streamingResults = new List(); + for (int i = 0; i < count; i++) + { + streamingResults.Add(rsx.Update(new TValue(series.Times[i], series.Values[i])).Value); + } + + var staticResults = Rsx.Calculate(series, period); + + Assert.Equal(streamingResults.Count, staticResults.Count); + for (int i = 0; i < count; i++) + { + Assert.Equal(streamingResults[i], staticResults.Values[i], 1e-9); + } + } + + [Fact] + public void SpanCalculate_Matches_Streaming() + { + int period = 14; + int count = 100; + var bars = _gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; + var rsx = new Rsx(period); + + var streamingResults = new List(); + for (int i = 0; i < count; i++) + { + streamingResults.Add(rsx.Update(new TValue(series.Times[i], series.Values[i])).Value); + } var spanInput = series.Values.ToArray(); var spanOutput = new double[count]; @@ -76,28 +102,24 @@ public class RsxTests for (int i = 0; i < count; i++) { - Assert.Equal(resultSeries.Values[i], spanOutput[i], 1e-9); + Assert.Equal(streamingResults[i], spanOutput[i], 1e-9); } } [Fact] - public void Reset_ClearsState() + public void Reset_Works() { var rsx = new Rsx(14); rsx.Update(new TValue(DateTime.UtcNow, 100)); rsx.Reset(); // After reset, it should behave like a new instance - // RSX initializes with 0 filters. - // If we feed it the same value, it should produce the same initial output. - // However, RSX output depends on change (v8), so first value sets LastF8 but v8=0. - var val1 = rsx.Update(new TValue(DateTime.UtcNow, 100)); Assert.Equal(50.0, val1.Value); // Neutral start } [Fact] - public void Chain_Works() + public void Chainability_Works() { var rsx = new Rsx(14); var rsx2 = new Rsx(rsx, 14); diff --git a/lib/momentum/vel/Vel.Tests.cs b/lib/momentum/vel/Vel.Tests.cs index c840a653..e53f8673 100644 --- a/lib/momentum/vel/Vel.Tests.cs +++ b/lib/momentum/vel/Vel.Tests.cs @@ -6,7 +6,7 @@ namespace QuanTAlib.Tests; public class VelTests { [Fact] - public void Vel_Constructor_ValidatesInput() + public void Constructor_InvalidPeriod_ThrowsArgumentException() { Assert.Throws(() => new Vel(0)); Assert.Throws(() => new Vel(-1)); @@ -16,7 +16,7 @@ public class VelTests } [Fact] - public void Vel_Calc_ReturnsValue() + public void BasicCalculation_DoesNotCrash() { var vel = new Vel(10); @@ -28,38 +28,25 @@ public class VelTests } [Fact] - public void Vel_Calc_IsNew_AcceptsParameter() + public void IsNew_Consistency() { var vel = new Vel(10); + var time = DateTime.UtcNow; - vel.Update(new TValue(DateTime.UtcNow, 100), isNew: true); - double value1 = vel.Last.Value; + // Update with isNew=true + var val1 = vel.Update(new TValue(time, 100), true); - vel.Update(new TValue(DateTime.UtcNow, 200), isNew: true); - double value2 = vel.Last.Value; + // Update with isNew=false (same time, different value) + vel.Update(new TValue(time, 105), false); - // Values should change with new bars - Assert.NotEqual(value1, value2); + // Update with isNew=false (same time, original value) - should match val1 if state rollback works + var val3 = vel.Update(new TValue(time, 100), false); + + Assert.Equal(val1.Value, val3.Value, 1e-9); } [Fact] - public void Vel_Calc_IsNew_False_UpdatesValue() - { - var vel = new Vel(10); - - vel.Update(new TValue(DateTime.UtcNow, 100)); - vel.Update(new TValue(DateTime.UtcNow, 110), isNew: true); - double beforeUpdate = vel.Last.Value; - - vel.Update(new TValue(DateTime.UtcNow, 120), isNew: false); - double afterUpdate = vel.Last.Value; - - // Update should change the value - Assert.NotEqual(beforeUpdate, afterUpdate); - } - - [Fact] - public void Vel_Reset_ClearsState() + public void Reset_Works() { var vel = new Vel(10); @@ -82,7 +69,7 @@ public class VelTests } [Fact] - public void Vel_IsHot_BecomesTrueWhenBufferFull() + public void IsHot_BecomesTrueWhenBufferFull() { var vel = new Vel(5); @@ -99,7 +86,7 @@ public class VelTests } [Fact] - public void Vel_CalculatesCorrectValue() + public void CalculatesCorrectValue() { var vel = new Vel(3); @@ -119,7 +106,7 @@ public class VelTests } [Fact] - public void Vel_StaticCalculate_Works() + public void StaticCalculate_Matches_Streaming() { var series = new TSeries(); series.Add(DateTime.UtcNow.Ticks, 10); @@ -138,7 +125,7 @@ public class VelTests } [Fact] - public void Vel_SpanCalc_MatchesTSeriesCalc() + public void SpanCalculate_Matches_Streaming() { var series = new TSeries(); double[] source = new double[100]; @@ -166,45 +153,12 @@ public class VelTests } [Fact] - public void Vel_AllModes_ProduceSameResult() + public void Chainability_Works() { - // Arrange - int period = 10; - var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123); - var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); - var series = bars.Close; + var vel = new Vel(10); + var vel2 = new Vel(vel, 10); - // 1. Batch Mode - var batchSeries = Vel.Calculate(series, period); - double expected = batchSeries.Last.Value; - - // 2. Span Mode - var tValues = series.Values.ToArray(); - var spanInput = new ReadOnlySpan(tValues); - var spanOutput = new double[tValues.Length]; - Vel.Calculate(spanInput, spanOutput, period); - double spanResult = spanOutput[^1]; - - // 3. Streaming Mode - var streamingInd = new Vel(period); - for (int i = 0; i < series.Count; i++) - { - streamingInd.Update(series[i]); - } - double streamingResult = streamingInd.Last.Value; - - // 4. Eventing Mode - var pubSource = new TSeries(); - var eventingInd = new Vel(pubSource, period); - for (int i = 0; i < series.Count; i++) - { - pubSource.Add(series[i]); - } - double eventingResult = eventingInd.Last.Value; - - // Assert - Assert.Equal(expected, spanResult, precision: 9); - Assert.Equal(expected, streamingResult, precision: 8); - Assert.Equal(expected, eventingResult, precision: 8); + vel.Update(new TValue(DateTime.UtcNow, 100)); + Assert.False(double.IsNaN(vel2.Last.Value)); } } diff --git a/lib/trends/alma/Alma.Tests.cs b/lib/trends/alma/Alma.Tests.cs index e2ac3e37..b1184d94 100644 --- a/lib/trends/alma/Alma.Tests.cs +++ b/lib/trends/alma/Alma.Tests.cs @@ -59,6 +59,7 @@ public class AlmaTests // Streaming var streamingResults = new TSeries(); + Assert.True(series.Count > 0); foreach (var item in series) { streamingResults.Add(almaStreaming.Update(item)); @@ -178,4 +179,155 @@ public class AlmaTests Assert.Equal(0, alma.Last.Value); Assert.False(alma.IsHot); } + + [Fact] + public void Alma_FirstValue_ReturnsExpected() + { + var alma = new Alma(10); + TValue result = alma.Update(new TValue(DateTime.UtcNow, 100)); + Assert.Equal(100.0, result.Value, 1e-9); + } + + [Fact] + public void Alma_Properties_Accessible() + { + var alma = new Alma(10); + Assert.False(alma.IsHot); + Assert.Equal(0, alma.Last.Value); + } + + [Fact] + public void Alma_Calc_IsNew_AcceptsParameter() + { + var alma = new Alma(10); + alma.Update(new TValue(DateTime.UtcNow, 100), isNew: true); + Assert.Equal(100, alma.Last.Value); + } + + [Fact] + public void Alma_IterativeCorrections_RestoreToOriginalState() + { + var alma = new Alma(10); + var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1); + + // Feed 10 new values + TValue tenthInput = default; + for (int i = 0; i < 10; i++) + { + var bar = gbm.Next(isNew: true); + tenthInput = new TValue(bar.Time, bar.Close); + alma.Update(tenthInput, isNew: true); + } + + // Remember state after 10 values + double valueAfterTen = alma.Last.Value; + + // Generate 9 corrections with isNew=false (different values) + for (int i = 0; i < 9; i++) + { + var bar = gbm.Next(isNew: false); + alma.Update(new TValue(bar.Time, bar.Close), isNew: false); + } + + // Feed the remembered 10th input again with isNew=false + TValue finalValue = alma.Update(tenthInput, isNew: false); + + // Should match the original state after 10 values + Assert.Equal(valueAfterTen, finalValue.Value, 1e-9); + } + + [Fact] + public void Alma_Infinity_Input_UsesLastValidValue() + { + var alma = new Alma(10); + alma.Update(new TValue(DateTime.UtcNow, 100)); + alma.Update(new TValue(DateTime.UtcNow, 110)); + + var resultPosInf = alma.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity)); + Assert.True(double.IsFinite(resultPosInf.Value)); + + var resultNegInf = alma.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity)); + Assert.True(double.IsFinite(resultNegInf.Value)); + } + + [Fact] + public void Alma_MultipleNaN_ContinuesWithLastValid() + { + var alma = new Alma(10); + alma.Update(new TValue(DateTime.UtcNow, 100)); + + var r1 = alma.Update(new TValue(DateTime.UtcNow, double.NaN)); + var r2 = alma.Update(new TValue(DateTime.UtcNow, double.NaN)); + + Assert.True(double.IsFinite(r1.Value)); + Assert.True(double.IsFinite(r2.Value)); + } + + [Fact] + public void Alma_AllModes_ProduceSameResult() + { + // Arrange + int period = 10; + var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123); + var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; + + // 1. Batch Mode + var batchSeries = Alma.Calculate(series, period); + double expected = batchSeries.Last.Value; + + // 2. Span Mode + var tValues = series.Values.ToArray(); + var spanInput = new ReadOnlySpan(tValues); + var spanOutput = new double[tValues.Length]; + Alma.Calculate(spanInput, spanOutput, period); + double spanResult = spanOutput[^1]; + + // 3. Streaming Mode + var streamingInd = new Alma(period); + for (int i = 0; i < series.Count; i++) + { + streamingInd.Update(series[i]); + } + double streamingResult = streamingInd.Last.Value; + + // 4. Eventing Mode + var pubSource = new TSeries(); + var eventingInd = new Alma(pubSource, period); + for (int i = 0; i < series.Count; i++) + { + pubSource.Add(series[i]); + } + double eventingResult = eventingInd.Last.Value; + + // Assert + Assert.Equal(expected, spanResult, 1e-9); + Assert.Equal(expected, streamingResult, 1e-9); + Assert.Equal(expected, eventingResult, 1e-9); + } + + [Fact] + public void Alma_SpanCalc_ValidatesInput() + { + double[] source = [1, 2, 3, 4, 5]; + double[] output = new double[5]; + double[] wrongSizeOutput = new double[3]; + + Assert.Throws(() => Alma.Calculate(source.AsSpan(), output.AsSpan(), 0)); + Assert.Throws(() => Alma.Calculate(source.AsSpan(), wrongSizeOutput.AsSpan(), 3)); + } + + [Fact] + public void Alma_SpanCalc_HandlesNaN() + { + double[] source = [100, 110, double.NaN, 120, 130]; + double[] output = new double[5]; + + Alma.Calculate(source.AsSpan(), output.AsSpan(), 3); + + foreach (var val in output) + { + Assert.True(double.IsFinite(val)); + } + } } diff --git a/lib/trends/conv/Conv.Tests.cs b/lib/trends/conv/Conv.Tests.cs index 615ce0f2..02f5e69a 100644 --- a/lib/trends/conv/Conv.Tests.cs +++ b/lib/trends/conv/Conv.Tests.cs @@ -129,4 +129,107 @@ public class ConvTests var res = conv.Update(new TValue(DateTime.UtcNow, double.NaN)); Assert.True(double.IsNaN(res.Value)); } + + [Fact] + public void IterativeCorrections_RestoreToOriginalState() + { + double[] kernel = [0.5, 1.0]; + var conv = new Conv(kernel); + var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1); + + // Feed 10 new values + TValue tenthInput = default; + for (int i = 0; i < 10; i++) + { + var bar = gbm.Next(isNew: true); + tenthInput = new TValue(bar.Time, bar.Close); + conv.Update(tenthInput, isNew: true); + } + + // Remember state after 10 values + double valueAfterTen = conv.Last.Value; + + // Generate 9 corrections with isNew=false (different values) + for (int i = 0; i < 9; i++) + { + var bar = gbm.Next(isNew: false); + conv.Update(new TValue(bar.Time, bar.Close), isNew: false); + } + + // Feed the remembered 10th input again with isNew=false + TValue finalValue = conv.Update(tenthInput, isNew: false); + + // Should match the original state after 10 values + Assert.Equal(valueAfterTen, finalValue.Value, 1e-9); + } + + [Fact] + public void AllModes_ProduceSameResult() + { + // Arrange + double[] kernel = [0.1, 0.2, 0.3, 0.4]; + var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123); + var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; + + // 1. Batch Mode + var batchSeries = Conv.Calculate(series, kernel); + double expected = batchSeries.Last.Value; + + // 2. Span Mode + var tValues = series.Values.ToArray(); + var spanInput = new ReadOnlySpan(tValues); + var spanOutput = new double[tValues.Length]; + Conv.Calculate(spanInput, spanOutput, kernel); + double spanResult = spanOutput[^1]; + + // 3. Streaming Mode + var streamingInd = new Conv(kernel); + for (int i = 0; i < series.Count; i++) + { + streamingInd.Update(series[i]); + } + double streamingResult = streamingInd.Last.Value; + + // 4. Eventing Mode + var pubSource = new TSeries(); + var eventingInd = new Conv(pubSource, kernel); + for (int i = 0; i < series.Count; i++) + { + pubSource.Add(series[i]); + } + double eventingResult = eventingInd.Last.Value; + + // Assert + Assert.Equal(expected, spanResult, 1e-9); + Assert.Equal(expected, streamingResult, 1e-9); + Assert.Equal(expected, eventingResult, 1e-9); + } + + [Fact] + public void SpanCalc_ValidatesInput() + { + double[] source = [1, 2, 3, 4, 5]; + double[] output = new double[5]; + double[] wrongSizeOutput = new double[3]; + double[] kernel = [0.5, 0.5]; + + Assert.Throws(() => Conv.Calculate(source.AsSpan(), output.AsSpan(), Array.Empty())); + Assert.Throws(() => Conv.Calculate(source.AsSpan(), wrongSizeOutput.AsSpan(), kernel)); + } + + [Fact] + public void SpanCalc_HandlesNaN() + { + double[] source = [100, 110, double.NaN, 120, 130]; + double[] output = new double[5]; + double[] kernel = [0.5, 0.5]; + + Conv.Calculate(source.AsSpan(), output.AsSpan(), kernel); + + foreach (var val in output) + { + Assert.True(double.IsFinite(val)); + } + } } diff --git a/lib/trends/dema/Dema.Tests.cs b/lib/trends/dema/Dema.Tests.cs index 58da6a14..4dd15251 100644 --- a/lib/trends/dema/Dema.Tests.cs +++ b/lib/trends/dema/Dema.Tests.cs @@ -160,6 +160,107 @@ public class DemaTests Assert.Equal(val.Value, output[i], 1e-9); } } + + [Fact] + public void Dema_Constructor_ValidatesInput() + { + Assert.Throws(() => new Dema(0)); + Assert.Throws(() => new Dema(-1)); + Assert.Throws(() => new Dema(0.0)); + Assert.Throws(() => new Dema(1.1)); + } + + [Fact] + public void Dema_Calc_IsNew_AcceptsParameter() + { + var dema = new Dema(10); + dema.Update(new TValue(DateTime.UtcNow, 100), isNew: true); + Assert.Equal(100, dema.Last.Value); + } + + [Fact] + public void Dema_Reset_ClearsState() + { + var dema = new Dema(10); + dema.Update(new TValue(DateTime.UtcNow, 100)); + dema.Update(new TValue(DateTime.UtcNow, 110)); + + dema.Reset(); + + Assert.Equal(0, dema.Last.Value); + Assert.False(dema.IsHot); + } + + [Fact] + public void Dema_IterativeCorrections_RestoreToOriginalState() + { + var dema = new Dema(10); + var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1); + + // Feed 10 new values + TValue tenthInput = default; + for (int i = 0; i < 10; i++) + { + var bar = gbm.Next(isNew: true); + tenthInput = new TValue(bar.Time, bar.Close); + dema.Update(tenthInput, isNew: true); + } + + // Remember state after 10 values + double valueAfterTen = dema.Last.Value; + + // Generate 9 corrections with isNew=false (different values) + for (int i = 0; i < 9; i++) + { + var bar = gbm.Next(isNew: false); + dema.Update(new TValue(bar.Time, bar.Close), isNew: false); + } + + // Feed the remembered 10th input again with isNew=false + TValue finalValue = dema.Update(tenthInput, isNew: false); + + // Should match the original state after 10 values + Assert.Equal(valueAfterTen, finalValue.Value, 1e-9); + } + + [Fact] + public void Dema_NaN_Input_UsesLastValidValue() + { + var dema = new Dema(10); + dema.Update(new TValue(DateTime.UtcNow, 100)); + dema.Update(new TValue(DateTime.UtcNow, 110)); + + var resultAfterNaN = dema.Update(new TValue(DateTime.UtcNow, double.NaN)); + + Assert.True(double.IsFinite(resultAfterNaN.Value)); + Assert.NotEqual(0, resultAfterNaN.Value); + } + + [Fact] + public void Dema_SpanCalc_ValidatesInput() + { + double[] source = [1, 2, 3, 4, 5]; + double[] output = new double[5]; + double[] wrongSizeOutput = new double[3]; + + Assert.Throws(() => Dema.Calculate(source.AsSpan(), output.AsSpan(), 0)); + Assert.Throws(() => Dema.Calculate(source.AsSpan(), wrongSizeOutput.AsSpan(), 3)); + } + + [Fact] + public void Dema_SpanCalc_HandlesNaN() + { + double[] source = [100, 110, double.NaN, 120, 130]; + double[] output = new double[5]; + + Dema.Calculate(source.AsSpan(), output.AsSpan(), 3); + + foreach (var val in output) + { + Assert.True(double.IsFinite(val)); + } + } + [Fact] public void Dema_AllModes_ProduceSameResult() { diff --git a/lib/trends/dwma/Dwma.Tests.cs b/lib/trends/dwma/Dwma.Tests.cs index 3acc5825..9e19054d 100644 --- a/lib/trends/dwma/Dwma.Tests.cs +++ b/lib/trends/dwma/Dwma.Tests.cs @@ -99,4 +99,117 @@ public class DwmaTests Assert.Equal(source.Count, staticResult.Count); Assert.Equal(dwma.Last.Value, staticResult.Last.Value, 8); } + + [Fact] + public void IterativeCorrections_RestoreToOriginalState() + { + var dwma = new Dwma(10); + var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1); + + // Feed 10 new values + TValue tenthInput = default; + for (int i = 0; i < 10; i++) + { + var bar = gbm.Next(isNew: true); + tenthInput = new TValue(bar.Time, bar.Close); + dwma.Update(tenthInput, isNew: true); + } + + // Remember state after 10 values + double valueAfterTen = dwma.Last.Value; + + // Generate 9 corrections with isNew=false (different values) + for (int i = 0; i < 9; i++) + { + var bar = gbm.Next(isNew: false); + dwma.Update(new TValue(bar.Time, bar.Close), isNew: false); + } + + // Feed the remembered 10th input again with isNew=false + TValue finalValue = dwma.Update(tenthInput, isNew: false); + + // Should match the original state after 10 values + Assert.Equal(valueAfterTen, finalValue.Value, 1e-9); + } + + [Fact] + public void NaN_Input_UsesLastValidValue() + { + var dwma = new Dwma(5); + dwma.Update(new TValue(DateTime.UtcNow, 100)); + dwma.Update(new TValue(DateTime.UtcNow, 110)); + + var resultAfterNaN = dwma.Update(new TValue(DateTime.UtcNow, double.NaN)); + + Assert.True(double.IsFinite(resultAfterNaN.Value)); + Assert.NotEqual(0, resultAfterNaN.Value); + } + + [Fact] + public void SpanCalc_ValidatesInput() + { + double[] source = [1, 2, 3, 4, 5]; + double[] output = new double[5]; + double[] wrongSizeOutput = new double[3]; + + Assert.Throws(() => Dwma.Calculate(source.AsSpan(), output.AsSpan(), 0)); + Assert.Throws(() => Dwma.Calculate(source.AsSpan(), wrongSizeOutput.AsSpan(), 3)); + } + + [Fact] + public void SpanCalc_HandlesNaN() + { + double[] source = [100, 110, double.NaN, 120, 130]; + double[] output = new double[5]; + + Dwma.Calculate(source.AsSpan(), output.AsSpan(), 3); + + foreach (var val in output) + { + Assert.True(double.IsFinite(val)); + } + } + + [Fact] + public void AllModes_ProduceSameResult() + { + // Arrange + int period = 10; + var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123); + var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; + + // 1. Batch Mode + var batchSeries = Dwma.Calculate(series, period); + double expected = batchSeries.Last.Value; + + // 2. Span Mode + var tValues = series.Values.ToArray(); + var spanInput = new ReadOnlySpan(tValues); + var spanOutput = new double[tValues.Length]; + Dwma.Calculate(spanInput, spanOutput, period); + double spanResult = spanOutput[^1]; + + // 3. Streaming Mode + var streamingInd = new Dwma(period); + for (int i = 0; i < series.Count; i++) + { + streamingInd.Update(series[i]); + } + double streamingResult = streamingInd.Last.Value; + + // 4. Eventing Mode + var pubSource = new TSeries(); + var eventingInd = new Dwma(pubSource, period); + for (int i = 0; i < series.Count; i++) + { + pubSource.Add(series[i]); + } + double eventingResult = eventingInd.Last.Value; + + // Assert + Assert.Equal(expected, spanResult, precision: 9); + Assert.Equal(expected, streamingResult, precision: 9); + Assert.Equal(expected, eventingResult, precision: 9); + } } diff --git a/lib/trends/hma/Hma.Tests.cs b/lib/trends/hma/Hma.Tests.cs index 46d30204..897dc996 100644 --- a/lib/trends/hma/Hma.Tests.cs +++ b/lib/trends/hma/Hma.Tests.cs @@ -62,6 +62,7 @@ public class HmaTests // Streaming var streamingResults = new TSeries(); + Assert.True(series.Count > 0); foreach (var item in series) { streamingResults.Add(hmaStreaming.Update(item)); @@ -71,7 +72,7 @@ public class HmaTests var batchResults = hmaBatch.Update(series); Assert.Equal(streamingResults.Count, batchResults.Count); - for (int i = 0; i < streamingResults.Count; i++) + for (int i = 0; i < series.Count; i++) { Assert.Equal(streamingResults[i].Value, batchResults[i].Value, 1e-9); } @@ -152,4 +153,130 @@ public class HmaTests Assert.Equal(valueAfterCommit, hma.Last.Value, 1e-9); } + + [Fact] + public void Hma_Reset_ClearsState() + { + var hma = new Hma(10); + hma.Update(new TValue(DateTime.UtcNow, 100)); + hma.Update(new TValue(DateTime.UtcNow, 110)); + + hma.Reset(); + + Assert.Equal(0, hma.Last.Value); + Assert.False(hma.IsHot); + } + + [Fact] + public void Hma_IterativeCorrections_RestoreToOriginalState() + { + var hma = new Hma(10); + var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1); + + // Feed 10 new values + TValue tenthInput = default; + for (int i = 0; i < 10; i++) + { + var bar = gbm.Next(isNew: true); + tenthInput = new TValue(bar.Time, bar.Close); + hma.Update(tenthInput, isNew: true); + } + + // Remember state after 10 values + double valueAfterTen = hma.Last.Value; + + // Generate 9 corrections with isNew=false (different values) + for (int i = 0; i < 9; i++) + { + var bar = gbm.Next(isNew: false); + hma.Update(new TValue(bar.Time, bar.Close), isNew: false); + } + + // Feed the remembered 10th input again with isNew=false + TValue finalValue = hma.Update(tenthInput, isNew: false); + + // Should match the original state after 10 values + Assert.Equal(valueAfterTen, finalValue.Value, 1e-9); + } + + [Fact] + public void Hma_NaN_Input_UsesLastValidValue() + { + var hma = new Hma(5); + hma.Update(new TValue(DateTime.UtcNow, 100)); + hma.Update(new TValue(DateTime.UtcNow, 110)); + + var resultAfterNaN = hma.Update(new TValue(DateTime.UtcNow, double.NaN)); + + Assert.True(double.IsFinite(resultAfterNaN.Value)); + Assert.NotEqual(0, resultAfterNaN.Value); + } + + [Fact] + public void Hma_SpanCalc_ValidatesInput() + { + double[] source = [1, 2, 3, 4, 5]; + double[] output = new double[5]; + double[] wrongSizeOutput = new double[3]; + + Assert.Throws(() => Hma.Calculate(source.AsSpan(), output.AsSpan(), 0)); + Assert.Throws(() => Hma.Calculate(source.AsSpan(), wrongSizeOutput.AsSpan(), 3)); + } + + [Fact] + public void Hma_SpanCalc_HandlesNaN() + { + double[] source = [100, 110, double.NaN, 120, 130]; + double[] output = new double[5]; + + Hma.Calculate(source.AsSpan(), output.AsSpan(), 3); + + foreach (var val in output) + { + Assert.True(double.IsFinite(val)); + } + } + + [Fact] + public void Hma_AllModes_ProduceSameResult() + { + // Arrange + int period = 10; + var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123); + var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; + + // 1. Batch Mode + var batchSeries = Hma.Calculate(series, period); + double expected = batchSeries.Last.Value; + + // 2. Span Mode + var tValues = series.Values.ToArray(); + var spanInput = new ReadOnlySpan(tValues); + var spanOutput = new double[tValues.Length]; + Hma.Calculate(spanInput, spanOutput, period); + double spanResult = spanOutput[^1]; + + // 3. Streaming Mode + var streamingInd = new Hma(period); + for (int i = 0; i < series.Count; i++) + { + streamingInd.Update(series[i]); + } + double streamingResult = streamingInd.Last.Value; + + // 4. Eventing Mode + var pubSource = new TSeries(); + var eventingInd = new Hma(pubSource, period); + for (int i = 0; i < series.Count; i++) + { + pubSource.Add(series[i]); + } + double eventingResult = eventingInd.Last.Value; + + // Assert + Assert.Equal(expected, spanResult, precision: 9); + Assert.Equal(expected, streamingResult, precision: 9); + Assert.Equal(expected, eventingResult, precision: 9); + } } diff --git a/lib/trends/htit/Htit.Tests.cs b/lib/trends/htit/Htit.Tests.cs index 8088454a..7cc6a710 100644 --- a/lib/trends/htit/Htit.Tests.cs +++ b/lib/trends/htit/Htit.Tests.cs @@ -79,4 +79,122 @@ public class HtitTests Assert.Equal(100.0, htit.Last.Value); } + + [Fact] + public void Htit_Calc_IsNew_AcceptsParameter() + { + var htit = new Htit(); + htit.Update(new TValue(DateTime.UtcNow, 100), isNew: true); + Assert.Equal(100, htit.Last.Value); + } + + [Fact] + public void Htit_Reset_ClearsState() + { + var htit = new Htit(); + htit.Update(new TValue(DateTime.UtcNow, 100)); + htit.Update(new TValue(DateTime.UtcNow, 110)); + + htit.Reset(); + + Assert.Equal(0, htit.Last.Value); + Assert.False(htit.IsHot); + } + + [Fact] + public void Htit_IterativeCorrections_RestoreToOriginalState() + { + var htit = new Htit(); + var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1); + + // Feed 20 new values (needs > 12 for warmup) + TValue lastInput = default; + for (int i = 0; i < 20; i++) + { + var bar = gbm.Next(isNew: true); + lastInput = new TValue(bar.Time, bar.Close); + htit.Update(lastInput, isNew: true); + } + + // Remember state after 20 values + double valueAfterTwenty = htit.Last.Value; + + // Generate 9 corrections with isNew=false (different values) + for (int i = 0; i < 9; i++) + { + var bar = gbm.Next(isNew: false); + htit.Update(new TValue(bar.Time, bar.Close), isNew: false); + } + + // Feed the remembered 20th input again with isNew=false + TValue finalValue = htit.Update(lastInput, isNew: false); + + // Should match the original state after 20 values + Assert.Equal(valueAfterTwenty, finalValue.Value, 1e-9); + } + + [Fact] + public void Htit_SpanCalc_ValidatesInput() + { + double[] source = [1, 2, 3, 4, 5]; + double[] wrongSizeOutput = new double[3]; + + Assert.Throws(() => Htit.Calculate(source.AsSpan(), wrongSizeOutput.AsSpan())); + } + + [Fact] + public void Htit_SpanCalc_HandlesNaN() + { + double[] source = [100, 110, double.NaN, 120, 130]; + double[] output = new double[5]; + + Htit.Calculate(source.AsSpan(), output.AsSpan()); + + foreach (var val in output) + { + Assert.True(double.IsFinite(val)); + } + } + + [Fact] + public void Htit_AllModes_ProduceSameResult() + { + // Arrange + var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123); + var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; + + // 1. Batch Mode + var batchSeries = Htit.Calculate(series); + double expected = batchSeries.Last.Value; + + // 2. Span Mode + var tValues = series.Values.ToArray(); + var spanInput = new ReadOnlySpan(tValues); + var spanOutput = new double[tValues.Length]; + Htit.Calculate(spanInput, spanOutput); + double spanResult = spanOutput[^1]; + + // 3. Streaming Mode + var streamingInd = new Htit(); + for (int i = 0; i < series.Count; i++) + { + streamingInd.Update(series[i]); + } + double streamingResult = streamingInd.Last.Value; + + // 4. Eventing Mode + var pubSource = new TSeries(); + var eventingInd = new Htit(pubSource); + for (int i = 0; i < series.Count; i++) + { + pubSource.Add(series[i]); + } + double eventingResult = eventingInd.Last.Value; + + // Assert + Assert.Equal(expected, spanResult, precision: 9); + Assert.Equal(expected, streamingResult, precision: 9); + Assert.Equal(expected, eventingResult, precision: 9); + } } diff --git a/lib/trends/jma/Jma.Tests.cs b/lib/trends/jma/Jma.Tests.cs index b103415f..4ca12c4f 100644 --- a/lib/trends/jma/Jma.Tests.cs +++ b/lib/trends/jma/Jma.Tests.cs @@ -28,18 +28,18 @@ public class JmaTests } [Fact] - public void Jma_Calc_IsNew_AcceptsParameter() + public void Jma_SpanCalc_ValidatesInput() { - var jma = new Jma(10); + double[] source = [1, 2, 3, 4, 5]; + double[] output = new double[5]; + double[] wrongSizeOutput = new double[3]; - jma.Update(new TValue(DateTime.UtcNow, 100), isNew: true); - double value1 = jma.Last.Value; + // Period must be > 0 + Assert.Throws(() => Jma.Calculate(source.AsSpan(), output.AsSpan(), 0, 0, 1.0)); + Assert.Throws(() => Jma.Calculate(source.AsSpan(), output.AsSpan(), -1, 0, 1.0)); - jma.Update(new TValue(DateTime.UtcNow, 200), isNew: true); - double value2 = jma.Last.Value; - - // Values should change with new bars - Assert.NotEqual(value1, value2); + // Output must be same length as source + Assert.Throws(() => Jma.Calculate(source.AsSpan(), wrongSizeOutput.AsSpan(), 3, 0, 1.0)); } [Fact] @@ -236,4 +236,19 @@ public class JmaTests Assert.NotEqual(jmaPhase0.Last.Value, jmaPhase100.Last.Value); Assert.NotEqual(jmaPhase0.Last.Value, jmaPhaseMinus100.Last.Value); } + + + [Fact] + public void Jma_SpanCalc_HandlesNaN() + { + double[] source = [100, 110, double.NaN, 120, 130]; + double[] output = new double[5]; + + Jma.Calculate(source.AsSpan(), output.AsSpan(), 3); + + foreach (var val in output) + { + Assert.True(double.IsFinite(val)); + } + } } diff --git a/lib/trends/kama/Kama.Tests.cs b/lib/trends/kama/Kama.Tests.cs index db16ac79..7023fade 100644 --- a/lib/trends/kama/Kama.Tests.cs +++ b/lib/trends/kama/Kama.Tests.cs @@ -60,6 +60,7 @@ public class KamaTests // Streaming var streamingResults = new TSeries(); + Assert.True(series.Count > 0); foreach (var item in series) { streamingResults.Add(kamaStreaming.Update(item)); @@ -69,9 +70,9 @@ public class KamaTests var batchResults = kamaBatch.Update(series); Assert.Equal(streamingResults.Count, batchResults.Count); - for (int i = 0; i < streamingResults.Count; i++) + foreach (var (stream, batch) in streamingResults.Zip(batchResults)) { - Assert.Equal(streamingResults[i].Value, batchResults[i].Value, 1e-9); + Assert.Equal(stream.Value, batch.Value, 1e-9); } } @@ -168,4 +169,112 @@ public class KamaTests Assert.Equal(100, kama.Last.Value); } + + [Fact] + public void Kama_Calc_IsNew_AcceptsParameter() + { + var kama = new Kama(10); + kama.Update(new TValue(DateTime.UtcNow, 100), isNew: true); + Assert.Equal(100, kama.Last.Value); + } + + [Fact] + public void Kama_IterativeCorrections_RestoreToOriginalState() + { + var kama = new Kama(10); + var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1); + + // Feed 20 new values (enough to fill buffer and stabilize) + TValue lastInput = default; + for (int i = 0; i < 20; i++) + { + var bar = gbm.Next(isNew: true); + lastInput = new TValue(bar.Time, bar.Close); + kama.Update(lastInput, isNew: true); + } + + // Remember state + double valueAfter = kama.Last.Value; + + // Generate 5 corrections with isNew=false (different values) + for (int i = 0; i < 5; i++) + { + var bar = gbm.Next(isNew: false); + kama.Update(new TValue(bar.Time, bar.Close), isNew: false); + } + + // Feed the remembered last input again with isNew=false + TValue finalValue = kama.Update(lastInput, isNew: false); + + // Should match the original state + Assert.Equal(valueAfter, finalValue.Value, 1e-9); + } + + [Fact] + public void Kama_SpanCalc_ValidatesInput() + { + double[] source = [1, 2, 3, 4, 5]; + double[] output = new double[5]; + double[] wrongSizeOutput = new double[3]; + + Assert.Throws(() => Kama.Calculate(source.AsSpan(), output.AsSpan(), 0)); + Assert.Throws(() => Kama.Calculate(source.AsSpan(), wrongSizeOutput.AsSpan(), 3)); + } + + [Fact] + public void Kama_SpanCalc_HandlesNaN() + { + double[] source = [100, 110, double.NaN, 120, 130]; + double[] output = new double[5]; + + Kama.Calculate(source.AsSpan(), output.AsSpan(), 3); + + foreach (var val in output) + { + Assert.True(double.IsFinite(val)); + } + } + + [Fact] + public void Kama_AllModes_ProduceSameResult() + { + // Arrange + int period = 10; + var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123); + var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; + + // 1. Batch Mode + var batchSeries = Kama.Calculate(series, period); + double expected = batchSeries.Last.Value; + + // 2. Span Mode + var tValues = series.Values.ToArray(); + var spanInput = new ReadOnlySpan(tValues); + var spanOutput = new double[tValues.Length]; + Kama.Calculate(spanInput, spanOutput, period); + double spanResult = spanOutput[^1]; + + // 3. Streaming Mode + var streamingInd = new Kama(period); + for (int i = 0; i < series.Count; i++) + { + streamingInd.Update(series[i]); + } + double streamingResult = streamingInd.Last.Value; + + // 4. Eventing Mode + var pubSource = new TSeries(); + var eventingInd = new Kama(pubSource, period); + for (int i = 0; i < series.Count; i++) + { + pubSource.Add(series[i]); + } + double eventingResult = eventingInd.Last.Value; + + // Assert + Assert.Equal(expected, spanResult, precision: 9); + Assert.Equal(expected, streamingResult, precision: 9); + Assert.Equal(expected, eventingResult, precision: 9); + } } diff --git a/lib/trends/kama/Kama.cs b/lib/trends/kama/Kama.cs index a6c6f48c..2158c1d2 100644 --- a/lib/trends/kama/Kama.cs +++ b/lib/trends/kama/Kama.cs @@ -227,6 +227,12 @@ public sealed class Kama : ITValuePublisher return new TSeries(t, v); } + public static TSeries Calculate(TSeries source, int period, int fastPeriod = 2, int slowPeriod = 30) + { + var kama = new Kama(period, fastPeriod, slowPeriod); + return kama.Update(source); + } + public static void Calculate(ReadOnlySpan source, Span output, int period, int fastPeriod = 2, int slowPeriod = 30) { if (period <= 0) throw new ArgumentException("Period must be greater than 0", nameof(period)); diff --git a/lib/trends/lsma/Lsma.Tests.cs b/lib/trends/lsma/Lsma.Tests.cs index a96b2f5e..f3aa2bb1 100644 --- a/lib/trends/lsma/Lsma.Tests.cs +++ b/lib/trends/lsma/Lsma.Tests.cs @@ -195,4 +195,29 @@ public class LsmaTests var result = lsma.Update(new TValue(DateTime.UtcNow, 100)); Assert.Equal(100, result.Value); } + + [Fact] + public void IsHot_BecomesTrueWhenBufferFull() + { + int period = 5; + var lsma = new Lsma(period); + + for (int i = 0; i < period; i++) + { + Assert.False(lsma.IsHot); + lsma.Update(new TValue(DateTime.UtcNow, i)); + } + + Assert.True(lsma.IsHot); + } + + [Fact] + public void Chainability_Works() + { + var source = new TSeries(); + var lsma = new Lsma(source, 10); + + source.Add(new TValue(DateTime.UtcNow, 100)); + Assert.Equal(100, lsma.Last.Value); + } } diff --git a/lib/trends/mama/Mama.Tests.cs b/lib/trends/mama/Mama.Tests.cs index dd70b9ec..d1491b8d 100644 --- a/lib/trends/mama/Mama.Tests.cs +++ b/lib/trends/mama/Mama.Tests.cs @@ -99,4 +99,83 @@ public class MamaTests Assert.Equal(result1[25 + i].Value, result2[i].Value, 6); } } + + [Fact] + public void IsHot_BecomesTrueAfterWarmup() + { + var mama = new Mama(); + + // MAMA needs 6 bars to warmup (Index > 6) + for (int i = 0; i < 6; i++) + { + mama.Update(new TValue(DateTime.UtcNow, 100)); + Assert.False(mama.IsHot); + } + + mama.Update(new TValue(DateTime.UtcNow, 100)); + Assert.True(mama.IsHot); + } + + [Fact] + public void Reset_ClearsState() + { + var mama = new Mama(); + for (int i = 0; i < 10; i++) + { + mama.Update(new TValue(DateTime.UtcNow, 100)); + } + Assert.True(mama.IsHot); + + mama.Reset(); + + Assert.False(mama.IsHot); + Assert.True(double.IsNaN(mama.Last.Value)); + } + + [Fact] + public void Update_BarCorrection_UpdatesCorrectly() + { + var mama = new Mama(); + + // Warmup + for (int i = 0; i < 10; i++) + { + mama.Update(new TValue(DateTime.UtcNow, 100)); + } + + // New bar + var result1 = mama.Update(new TValue(DateTime.UtcNow, 110)); + + // Update same bar with different value + var result2 = mama.Update(new TValue(DateTime.UtcNow, 120), isNew: false); + + Assert.NotEqual(result1.Value, result2.Value); + + // Verify internal state by adding next bar + var result3 = mama.Update(new TValue(DateTime.UtcNow, 130)); + Assert.True(double.IsFinite(result3.Value)); + } + + [Fact] + public void Calculate_StaticMethod_MatchesObjectInstance() + { + var source = new TSeries(); + var gbm = new GBM(startPrice: 100, seed: 42); + + for (int i = 0; i < 50; i++) + { + var bar = gbm.Next(); + source.Add(bar.C); + } + + var mama = new Mama(); + var series1 = mama.Update(source); + var series2 = Mama.Calculate(source); + + Assert.Equal(series1.Count, series2.Count); + for (int i = 0; i < source.Count; i++) + { + Assert.Equal(series1[i].Value, series2[i].Value, 1e-9); + } + } } diff --git a/lib/trends/mama/Mama.cs b/lib/trends/mama/Mama.cs index 25d94c26..eb10e8d5 100644 --- a/lib/trends/mama/Mama.cs +++ b/lib/trends/mama/Mama.cs @@ -61,6 +61,11 @@ public sealed class Mama : ITValuePublisher } public void Init() + { + Reset(); + } + + public void Reset() { _state = default; _state.Mama = double.NaN; @@ -214,6 +219,12 @@ public sealed class Mama : ITValuePublisher return new TSeries(t, v); } + public static TSeries Calculate(TSeries source, double fastLimit = 0.5, double slowLimit = 0.05) + { + var mama = new Mama(fastLimit, slowLimit); + return mama.Update(source); + } + public static void Calculate(ReadOnlySpan source, Span output, double fastLimit = 0.5, double slowLimit = 0.05) { var mama = new Mama(fastLimit, slowLimit); diff --git a/lib/trends/mgdi/Mgdi.Tests.cs b/lib/trends/mgdi/Mgdi.Tests.cs index 4e53d29a..dc39c03f 100644 --- a/lib/trends/mgdi/Mgdi.Tests.cs +++ b/lib/trends/mgdi/Mgdi.Tests.cs @@ -96,4 +96,54 @@ public class MgdiTests Assert.Throws(() => new Mgdi(14, double.NaN)); Assert.Throws(() => new Mgdi(14, double.PositiveInfinity)); } + + [Fact] + public void Reset_ClearsState() + { + var mgdi = new Mgdi(14); + for (int i = 0; i < 20; i++) + { + mgdi.Update(new TValue(DateTime.UtcNow, 100)); + } + Assert.True(mgdi.IsHot); + + mgdi.Reset(); + + Assert.False(mgdi.IsHot); + Assert.Equal(0, mgdi.Last.Value); + } + + [Fact] + public void Update_BarCorrection_UpdatesCorrectly() + { + var mgdi = new Mgdi(14); + + // Warmup + for (int i = 0; i < 20; i++) + { + mgdi.Update(new TValue(DateTime.UtcNow, 100)); + } + + // New bar + var result1 = mgdi.Update(new TValue(DateTime.UtcNow, 110)); + + // Update same bar with different value + var result2 = mgdi.Update(new TValue(DateTime.UtcNow, 120), isNew: false); + + Assert.NotEqual(result1.Value, result2.Value); + + // Verify internal state by adding next bar + var result3 = mgdi.Update(new TValue(DateTime.UtcNow, 130)); + Assert.True(double.IsFinite(result3.Value)); + } + + [Fact] + public void Chainability_Works() + { + var source = new TSeries(); + var mgdi = new Mgdi(source, 14); + + source.Add(new TValue(DateTime.UtcNow, 100)); + Assert.Equal(100, mgdi.Last.Value); + } } diff --git a/lib/trends/rma/Rma.Tests.cs b/lib/trends/rma/Rma.Tests.cs index 8d8dca4b..e15e9aba 100644 --- a/lib/trends/rma/Rma.Tests.cs +++ b/lib/trends/rma/Rma.Tests.cs @@ -202,4 +202,14 @@ public class RmaTests // Result should be finite (not NaN) Assert.True(double.IsFinite(resultAfterNaN.Value)); } + + [Fact] + public void Chainability_Works() + { + var source = new TSeries(); + var rma = new Rma(source, 10); + + source.Add(new TValue(DateTime.UtcNow, 100)); + Assert.Equal(100, rma.Last.Value, 1e-9); + } } diff --git a/lib/trends/sma/Sma.Tests.cs b/lib/trends/sma/Sma.Tests.cs index cd0bfda7..432c2a67 100644 --- a/lib/trends/sma/Sma.Tests.cs +++ b/lib/trends/sma/Sma.Tests.cs @@ -506,4 +506,14 @@ public class SmaTests Assert.Equal(expected, streamingResult, precision: 9); Assert.Equal(expected, eventingResult, precision: 9); } + + [Fact] + public void Chainability_Works() + { + var source = new TSeries(); + var sma = new Sma(source, 10); + + source.Add(new TValue(DateTime.UtcNow, 100)); + Assert.Equal(100, sma.Last.Value); + } } diff --git a/lib/trends/super/Super.Tests.cs b/lib/trends/super/Super.Tests.cs index e1d1a80e..fd0e0a98 100644 --- a/lib/trends/super/Super.Tests.cs +++ b/lib/trends/super/Super.Tests.cs @@ -140,4 +140,50 @@ public class SuperTests Assert.Throws(() => new Super(10, 0)); Assert.Throws(() => new Super(10, -1.0)); } + + [Fact] + public void StaticCalculate_Matches_Streaming() + { + var gbm = new GBM(); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + var super = new Super(10, 3.0); + var streamingResults = new List(); + for (int i = 0; i < bars.Count; i++) + { + streamingResults.Add(super.Update(bars[i]).Value); + } + + var staticResults = Super.Calculate(bars, 10, 3.0); + + Assert.Equal(streamingResults.Count, staticResults.Count); + for (int i = 0; i < staticResults.Count; i++) + { + if (double.IsNaN(streamingResults[i])) + { + Assert.True(double.IsNaN(staticResults.Values[i])); + } + else + { + Assert.Equal(streamingResults[i], staticResults.Values[i], 1e-9); + } + } + } + + [Fact] + public void Chainability_Works() + { + var super = new Super(10, 3.0); + var gbm = new GBM(); + var bars = gbm.Fetch(10, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // Test TBarSeries chain + var result = super.Update(bars); + Assert.NotNull(result); + Assert.IsType(result); + + // Test TBar chain (returns TValue) + var result2 = super.Update(bars[0]); + Assert.IsType(result2); + } } diff --git a/lib/trends/super/Super.cs b/lib/trends/super/Super.cs index c57685cc..fb77f8ae 100644 --- a/lib/trends/super/Super.cs +++ b/lib/trends/super/Super.cs @@ -217,4 +217,10 @@ public sealed class Super : ITValuePublisher return new TSeries(t, v); } + + public static TSeries Calculate(TBarSeries source, int period = 10, double multiplier = 3.0) + { + var indicator = new Super(period, multiplier); + return indicator.Update(source); + } } diff --git a/lib/trends/t3/T3.Tests.cs b/lib/trends/t3/T3.Tests.cs index eebd7580..8886b774 100644 --- a/lib/trends/t3/T3.Tests.cs +++ b/lib/trends/t3/T3.Tests.cs @@ -1,131 +1,173 @@ -using Xunit; using System; +using System.Collections.Generic; +using Xunit; -namespace QuanTAlib.Tests; +namespace QuanTAlib; public class T3Tests { [Fact] - public void T3_Constructor_Period_ValidatesInput() + public void BasicCalculation_DoesNotCrash() + { + var t3 = new T3(5, 0.7); + var gbm = new GBM(); + var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < bars.Count; i++) + { + t3.Update(new TValue(bars[i].Time, bars[i].Close)); + } + + Assert.True(double.IsFinite(t3.Last.Value)); + } + + [Fact] + public void IsNew_Consistency() + { + var t3 = new T3(5, 0.7); + var gbm = new GBM(); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // Feed first 99 + for (int i = 0; i < 99; i++) + { + t3.Update(new TValue(bars[i].Time, bars[i].Close)); + } + + // Update with 100th point (isNew=true) + t3.Update(new TValue(bars[99].Time, bars[99].Close), true); + + // Update with modified 100th point (isNew=false) + var val2 = t3.Update(new TValue(bars[99].Time, bars[99].Close + 1.0), false); + + // Create new instance and feed up to modified + var t3_2 = new T3(5, 0.7); + for (int i = 0; i < 99; i++) + { + t3_2.Update(new TValue(bars[i].Time, bars[i].Close)); + } + var val3 = t3_2.Update(new TValue(bars[99].Time, bars[99].Close + 1.0), true); + + Assert.Equal(val3.Value, val2.Value, 1e-9); + } + + [Fact] + public void Reset_Works() + { + var t3 = new T3(5, 0.7); + var gbm = new GBM(); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < bars.Count; i++) + { + t3.Update(new TValue(bars[i].Time, bars[i].Close)); + } + + t3.Reset(); + Assert.Equal(0, t3.Last.Value); + Assert.False(t3.IsHot); + + // Feed again + for (int i = 0; i < bars.Count; i++) + { + t3.Update(new TValue(bars[i].Time, bars[i].Close)); + } + + Assert.True(double.IsFinite(t3.Last.Value)); + } + + [Fact] + public void TSeries_Update_Matches_Streaming() + { + var t3 = new T3(5, 0.7); + var gbm = new GBM(); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; + + var streamingResults = new List(); + for (int i = 0; i < series.Count; i++) + { + streamingResults.Add(t3.Update(series[i]).Value); + } + + var t3_2 = new T3(5, 0.7); + var seriesResults = t3_2.Update(series); + + Assert.Equal(streamingResults.Count, seriesResults.Count); + for (int i = 0; i < seriesResults.Count; i++) + { + Assert.Equal(streamingResults[i], seriesResults.Values[i], 1e-9); + } + } + + [Fact] + public void StaticCalculate_Matches_Streaming() + { + var gbm = new GBM(); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; + + var t3 = new T3(5, 0.7); + var streamingResults = new List(); + for (int i = 0; i < series.Count; i++) + { + streamingResults.Add(t3.Update(series[i]).Value); + } + + var staticResults = T3.Calculate(series, 5, 0.7); + + Assert.Equal(streamingResults.Count, staticResults.Count); + for (int i = 0; i < staticResults.Count; i++) + { + Assert.Equal(streamingResults[i], staticResults.Values[i], 1e-9); + } + } + + [Fact] + public void StaticCalculateSpan_Matches_Streaming() + { + var gbm = new GBM(); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; + + var t3 = new T3(5, 0.7); + var streamingResults = new List(); + for (int i = 0; i < series.Count; i++) + { + streamingResults.Add(t3.Update(series[i]).Value); + } + + var spanResults = new double[series.Count]; + T3.Calculate(series.Values, spanResults, 5, 0.7); + + for (int i = 0; i < spanResults.Length; i++) + { + Assert.Equal(streamingResults[i], spanResults[i], 1e-9); + } + } + + [Fact] + public void Chainability_Works() + { + var t3 = new T3(5, 0.7); + var gbm = new GBM(); + var bars = gbm.Fetch(10, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; + + // Test TSeries chain + var result = t3.Update(series); + Assert.NotNull(result); + Assert.IsType(result); + + // Test TValue chain + var result2 = t3.Update(series[0]); + Assert.IsType(result2); + } + + [Fact] + public void Constructor_InvalidParameters_ThrowsArgumentException() { Assert.Throws(() => new T3(0)); Assert.Throws(() => new T3(-1)); - - var t3 = new T3(10); - Assert.NotNull(t3); - } - - [Fact] - public void T3_ConstantInput_ConvergesToInput() - { - var t3 = new T3(5, 0.7); - double input = 100.0; - - // Feed enough values for T3 to converge (it has 6 cascaded EMAs) - for(int i = 0; i < 100; i++) - { - t3.Update(new TValue(DateTime.UtcNow, input)); - } - - Assert.Equal(input, t3.Last.Value, 1e-9); - } - - [Fact] - public void T3_Parameters_AffectResult() - { - // Different volume factors should produce different results for changing data - var t3_low_v = new T3(10, 0.1); - var t3_high_v = new T3(10, 0.9); - - var series = new TSeries(); - series.Add(DateTime.UtcNow, 100); - series.Add(DateTime.UtcNow.AddMinutes(1), 110); - series.Add(DateTime.UtcNow.AddMinutes(2), 120); - - t3_low_v.Update(series); - t3_high_v.Update(series); - - Assert.NotEqual(t3_low_v.Last.Value, t3_high_v.Last.Value); - } - - [Fact] - public void T3_Reset_ResetsState() - { - var t3 = new T3(10); - t3.Update(new TValue(DateTime.UtcNow, 100)); - t3.Update(new TValue(DateTime.UtcNow, 110)); - - Assert.True(t3.IsHot); - Assert.NotEqual(0, t3.Last.Value); - - t3.Reset(); - - Assert.False(t3.IsHot); - Assert.Equal(0, t3.Last.Value); - - // Should accept new data as if fresh - t3.Update(new TValue(DateTime.UtcNow, 50)); - Assert.Equal(50, t3.Last.Value, 1e-9); // First value logic: output = input - } - - [Fact] - public void T3_Eventing_Works() - { - var source = new TSeries(); - var t3 = new T3(source, 10); - double lastVal = 0; - - t3.Pub += (v) => lastVal = v.Value; - - source.Add(new TValue(DateTime.UtcNow, 100)); - Assert.Equal(100, lastVal, 1e-9); - - source.Add(new TValue(DateTime.UtcNow, 110)); - Assert.NotEqual(100, lastVal); - Assert.NotEqual(0, lastVal); - } - - [Fact] - public void T3_SpanTests() - { - var series = new TSeries(); - int count = 100; - for(int i=0; i(); + for (int i = 0; i < series.Count; i++) + { + streamingResults.Add(tema.Update(series[i]).Value); + } + + var tema2 = new Tema(10); + var seriesResults = tema2.Update(series); + + Assert.Equal(streamingResults.Count, seriesResults.Count); + for (int i = 0; i < seriesResults.Count; i++) + { + Assert.Equal(streamingResults[i], seriesResults.Values[i], 1e-9); + } + } + + [Fact] + public void StaticCalculate_Matches_Streaming() + { + var gbm = new GBM(); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; + + var tema = new Tema(10); + var streamingResults = new List(); + for (int i = 0; i < series.Count; i++) + { + streamingResults.Add(tema.Update(series[i]).Value); + } + + var staticResults = Tema.Calculate(series, 10); + + Assert.Equal(streamingResults.Count, staticResults.Count); + for (int i = 0; i < staticResults.Count; i++) + { + Assert.Equal(streamingResults[i], staticResults.Values[i], 1e-9); + } + } + + [Fact] + public void StaticCalculateSpan_Matches_Streaming() + { + var gbm = new GBM(); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; + + var tema = new Tema(10); + var streamingResults = new List(); + for (int i = 0; i < series.Count; i++) + { + streamingResults.Add(tema.Update(series[i]).Value); + } + + var spanResults = new double[series.Count]; + Tema.Calculate(series.Values, spanResults, 10); + + for (int i = 0; i < spanResults.Length; i++) + { + Assert.Equal(streamingResults[i], spanResults[i], 1e-9); + } + } + + [Fact] + public void Chainability_Works() + { + var tema = new Tema(10); + var gbm = new GBM(); + var bars = gbm.Fetch(10, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; + + // Test TSeries chain + var result = tema.Update(series); + Assert.NotNull(result); + Assert.IsType(result); + + // Test TValue chain + var result2 = tema.Update(series[0]); + Assert.IsType(result2); + } + + [Fact] + public void Constructor_InvalidParameters_ThrowsArgumentException() { Assert.Throws(() => new Tema(0)); Assert.Throws(() => new Tema(-1)); - - var tema = new Tema(10); - Assert.NotNull(tema); - } - - [Fact] - public void Tema_Constructor_Alpha_ValidatesInput() - { Assert.Throws(() => new Tema(0.0)); - Assert.Throws(() => new Tema(-0.1)); - Assert.Throws(() => new Tema(1.1)); - - var tema = new Tema(0.5); - Assert.NotNull(tema); - } - - [Fact] - public void Tema_Calc_ReturnsValue() - { - var tema = new Tema(10); - - Assert.Equal(0, tema.Last.Value); - - TValue result = tema.Update(new TValue(DateTime.UtcNow, 100)); - - Assert.True(result.Value > 0); - Assert.Equal(result.Value, tema.Last.Value); - } - - [Fact] - public void Tema_Calc_IsNew_AcceptsParameter() - { - var tema = new Tema(10); - - tema.Update(new TValue(DateTime.UtcNow, 100), isNew: true); - double value1 = tema.Last.Value; - - tema.Update(new TValue(DateTime.UtcNow, 105), isNew: true); - double value2 = tema.Last.Value; - - // Values should change with new bars - Assert.NotEqual(value1, value2); - } - - [Fact] - public void Tema_Calc_IsNew_False_UpdatesValue() - { - var tema = new Tema(10); - - tema.Update(new TValue(DateTime.UtcNow, 100)); - tema.Update(new TValue(DateTime.UtcNow, 110), isNew: true); - double beforeUpdate = tema.Last.Value; - - tema.Update(new TValue(DateTime.UtcNow, 120), isNew: false); - double afterUpdate = tema.Last.Value; - - // Update should change the value - Assert.NotEqual(beforeUpdate, afterUpdate); - } - - [Fact] - public void Tema_Reset_ClearsState() - { - var tema = new Tema(10); - - tema.Update(new TValue(DateTime.UtcNow, 100)); - tema.Update(new TValue(DateTime.UtcNow, 105)); - double valueBefore = tema.Last.Value; - - tema.Reset(); - - Assert.Equal(0, tema.Last.Value); - - // After reset, should accept new values - tema.Update(new TValue(DateTime.UtcNow, 50)); - Assert.NotEqual(0, tema.Last.Value); - Assert.NotEqual(valueBefore, tema.Last.Value); - } - - [Fact] - public void Tema_Properties_Accessible() - { - var tema = new Tema(10); - - Assert.Equal(0, tema.Last.Value); - Assert.False(tema.IsHot); - - tema.Update(new TValue(DateTime.UtcNow, 100)); - - Assert.NotEqual(0, tema.Last.Value); - } - - [Fact] - public void Tema_IsHot_BecomesTrueAfterWarmup() - { - var tema = new Tema(10); - - // Initially IsHot should be false - Assert.False(tema.IsHot); - - // TEMA needs more warmup than EMA due to triple smoothing - int steps = 0; - while (!tema.IsHot && steps < 1000) - { - tema.Update(new TValue(DateTime.UtcNow, 100)); - steps++; - } - - Assert.True(tema.IsHot); - Assert.True(steps > 0); - } - - [Fact] - public void Tema_PeriodEquivalence_BothConstructorsWork() - { - int period = 20; - double alpha = 2.0 / (period + 1); - - var temaPeriod = new Tema(period); - var temaAlpha = new Tema(alpha); - - // Both should accept Calc calls and produce same result - TValue result1 = temaPeriod.Update(new TValue(DateTime.UtcNow, 100)); - TValue result2 = temaAlpha.Update(new TValue(DateTime.UtcNow, 100)); - - Assert.Equal(result1.Value, result2.Value, 1e-10); - } - - [Fact] - public void Tema_IterativeCorrections_RestoreToOriginalState() - { - var tema = new Tema(10); - var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1); - - // Feed 10 new values - TValue tenthInput = default; - for (int i = 0; i < 10; i++) - { - var bar = gbm.Next(isNew: true); - tenthInput = new TValue(bar.Time, bar.Close); - tema.Update(tenthInput, isNew: true); - } - - // Remember TEMA state after 10 values - double temaAfterTen = tema.Last.Value; - - // Generate 9 corrections with isNew=false (different values) - for (int i = 0; i < 9; i++) - { - var bar = gbm.Next(isNew: false); - tema.Update(new TValue(bar.Time, bar.Close), isNew: false); - } - - // Feed the remembered 10th input again with isNew=false - TValue finalTema = tema.Update(tenthInput, isNew: false); - - // TEMA should match the original state after 10 values - Assert.Equal(temaAfterTen, finalTema.Value, 1e-10); - } - - [Fact] - public void Tema_BatchCalc_MatchesIterativeCalc() - { - var temaIterative = new Tema(10); - var temaBatch = new Tema(10); - var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1); - - // Generate data - var series = new TSeries(); - for (int i = 0; i < 100; i++) - { - var bar = gbm.Next(isNew: true); - series.Add(bar.Time, bar.Close); - } - - Assert.True(series.Count > 0); - - // Calculate iteratively - var iterativeResults = new TSeries(); - foreach (var item in series) - { - iterativeResults.Add(temaIterative.Update(item)); - } - - // Calculate batch - var batchResults = temaBatch.Update(series); - - // Compare - Assert.Equal(iterativeResults.Count, batchResults.Count); - for (int i = 0; i < iterativeResults.Count; i++) - { - Assert.Equal(iterativeResults[i].Value, batchResults[i].Value, 1e-10); - Assert.Equal(iterativeResults[i].Time, batchResults[i].Time); - } - } - - [Fact] - public void Tema_NaN_Input_UsesLastValidValue() - { - var tema = new Tema(10); - - // Feed some valid values - tema.Update(new TValue(DateTime.UtcNow, 100)); - tema.Update(new TValue(DateTime.UtcNow, 110)); - - // Feed NaN - should use last valid value (110) - var resultAfterNaN = tema.Update(new TValue(DateTime.UtcNow, double.NaN)); - - // Result should be finite (not NaN) - Assert.True(double.IsFinite(resultAfterNaN.Value)); - Assert.NotEqual(0, resultAfterNaN.Value); - } - - [Fact] - public void Tema_SpanCalc_MatchesTSeriesCalc() - { - var series = new TSeries(); - double[] source = new double[100]; - double[] output = new double[100]; - - var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42); - for (int i = 0; i < 100; i++) - { - var bar = gbm.Next(isNew: true); - source[i] = bar.Close; - series.Add(bar.Time, bar.Close); - } - - // Calculate with TSeries API - var tseriesResult = Tema.Calculate(series, 10); - - // Calculate with Span API - Tema.Calculate(source.AsSpan(), output.AsSpan(), 10); - - // Compare results - for (int i = 0; i < 100; i++) - { - Assert.Equal(tseriesResult[i].Value, output[i], 1e-9); - } - } - - [Fact] - public void Tema_SpanCalc_ZeroAllocation() - { - double[] source = new double[10000]; - - double[] output = new double[10000]; - var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42); - for (int i = 0; i < source.Length; i++) - source[i] = gbm.Next().Close; - - // Warm up - Tema.Calculate(source.AsSpan(), output.AsSpan(), 100); - - // This test verifies the method runs without throwing - Assert.True(double.IsFinite(output[^1])); - } - [Fact] - public void Tema_AllModes_ProduceSameResult() - { - // Arrange - int period = 10; - var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123); - var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); - var series = bars.Close; - - // 1. Batch Mode - var batchSeries = Tema.Calculate(series, period); - double expected = batchSeries.Last.Value; - - // 2. Span Mode - var tValues = series.Values.ToArray(); - var spanInput = new ReadOnlySpan(tValues); - var spanOutput = new double[tValues.Length]; - Tema.Calculate(spanInput, spanOutput, period); - double spanResult = spanOutput[^1]; - - // 3. Streaming Mode - var streamingInd = new Tema(period); - for (int i = 0; i < series.Count; i++) - { - streamingInd.Update(series[i]); - } - double streamingResult = streamingInd.Last.Value; - - // 4. Eventing Mode - var pubSource = new TSeries(); - var eventingInd = new Tema(pubSource, period); - for (int i = 0; i < series.Count; i++) - { - pubSource.Add(series[i]); - } - double eventingResult = eventingInd.Last.Value; - - // Assert - Assert.Equal(expected, spanResult, precision: 9); - Assert.Equal(expected, streamingResult, precision: 9); - Assert.Equal(expected, eventingResult, precision: 9); + Assert.Throws(() => new Tema(1.0)); } } diff --git a/lib/trends/trima/Trima.Tests.cs b/lib/trends/trima/Trima.Tests.cs index d71f6df0..d0876e9b 100644 --- a/lib/trends/trima/Trima.Tests.cs +++ b/lib/trends/trima/Trima.Tests.cs @@ -1,45 +1,173 @@ +using System; +using System.Collections.Generic; using Xunit; -using QuanTAlib; -namespace QuanTAlib.Tests; +namespace QuanTAlib; public class TrimaTests { [Fact] - public void StateRestoration_IsCorrect() + public void BasicCalculation_DoesNotCrash() { - // Arrange - int period = 4; - var trimaStreaming = new Trima(period); - var trimaBatch = new Trima(period); + var trima = new Trima(10); + var gbm = new GBM(); + var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < bars.Count; i++) + { + trima.Update(new TValue(bars[i].Time, bars[i].Close)); + } + + Assert.True(double.IsFinite(trima.Last.Value)); + } + + [Fact] + public void IsNew_Consistency() + { + var trima = new Trima(10); + var gbm = new GBM(); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // Feed first 99 + for (int i = 0; i < 99; i++) + { + trima.Update(new TValue(bars[i].Time, bars[i].Close)); + } + + // Update with 100th point (isNew=true) + trima.Update(new TValue(bars[99].Time, bars[99].Close), true); + + // Update with modified 100th point (isNew=false) + var val2 = trima.Update(new TValue(bars[99].Time, bars[99].Close + 1.0), false); + + // Create new instance and feed up to modified + var trima2 = new Trima(10); + for (int i = 0; i < 99; i++) + { + trima2.Update(new TValue(bars[i].Time, bars[i].Close)); + } + var val3 = trima2.Update(new TValue(bars[99].Time, bars[99].Close + 1.0), true); + + Assert.Equal(val3.Value, val2.Value, 1e-9); + } + + [Fact] + public void Reset_Works() + { + var trima = new Trima(10); + var gbm = new GBM(); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < bars.Count; i++) + { + trima.Update(new TValue(bars[i].Time, bars[i].Close)); + } + + trima.Reset(); + Assert.Equal(0, trima.Last.Value); + Assert.False(trima.IsHot); - // Generate enough data to fill the buffers and have some history - int count = 50; - var data = new TSeries(); - for (int i = 0; i < count; i++) + // Feed again + for (int i = 0; i < bars.Count; i++) { - data.Add(new TValue(DateTime.UtcNow.AddMinutes(i), 100 + i)); + trima.Update(new TValue(bars[i].Time, bars[i].Close)); + } + + Assert.True(double.IsFinite(trima.Last.Value)); + } + + [Fact] + public void TSeries_Update_Matches_Streaming() + { + var trima = new Trima(10); + var gbm = new GBM(); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; + + var streamingResults = new List(); + for (int i = 0; i < series.Count; i++) + { + streamingResults.Add(trima.Update(series[i]).Value); } - // Act - // 1. Feed streaming instance - Assert.True(data.Count > 0); - for (int i = 0; i < data.Count; i++) + var trima2 = new Trima(10); + var seriesResults = trima2.Update(series); + + Assert.Equal(streamingResults.Count, seriesResults.Count); + for (int i = 0; i < seriesResults.Count; i++) { - trimaStreaming.Update(data[i]); + Assert.Equal(streamingResults[i], seriesResults.Values[i], 1e-9); } + } + + [Fact] + public void StaticCalculate_Matches_Streaming() + { + var gbm = new GBM(); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; + + var trima = new Trima(10); + var streamingResults = new List(); + for (int i = 0; i < series.Count; i++) + { + streamingResults.Add(trima.Update(series[i]).Value); + } + + var staticResults = Trima.Calculate(series, 10); + + Assert.Equal(streamingResults.Count, staticResults.Count); + for (int i = 0; i < staticResults.Count; i++) + { + Assert.Equal(streamingResults[i], staticResults.Values[i], 1e-9); + } + } - // 2. Feed batch instance with all but the last point first, then the last point - // Actually, the Update(TSeries) method is supposed to handle the whole series and leave the state ready for the NEXT point. - // So let's feed the whole series to batch instance. - trimaBatch.Update(data); + [Fact] + public void StaticCalculateSpan_Matches_Streaming() + { + var gbm = new GBM(); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; + + var trima = new Trima(10); + var streamingResults = new List(); + for (int i = 0; i < series.Count; i++) + { + streamingResults.Add(trima.Update(series[i]).Value); + } + + var spanResults = new double[series.Count]; + Trima.Calculate(series.Values, spanResults, 10); + + for (int i = 0; i < spanResults.Length; i++) + { + Assert.Equal(streamingResults[i], spanResults[i], 1e-9); + } + } - // 3. Now feed one NEW point to both - var newPoint = new TValue(DateTime.UtcNow.AddMinutes(count), 200); - var resultStreaming = trimaStreaming.Update(newPoint); - var resultBatch = trimaBatch.Update(newPoint); + [Fact] + public void Chainability_Works() + { + var trima = new Trima(10); + var gbm = new GBM(); + var bars = gbm.Fetch(10, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; + + // Test TSeries chain + var result = trima.Update(series); + Assert.NotNull(result); + Assert.IsType(result); + + // Test TValue chain + var result2 = trima.Update(series[0]); + Assert.IsType(result2); + } - // Assert - Assert.Equal(resultStreaming.Value, resultBatch.Value, precision: 9); + [Fact] + public void Constructor_InvalidParameters_ThrowsArgumentException() + { + Assert.Throws(() => new Trima(0)); + Assert.Throws(() => new Trima(-1)); } } diff --git a/lib/trends/vidya/Vidya.Tests.cs b/lib/trends/vidya/Vidya.Tests.cs index 30cfdf22..75b39ee1 100644 --- a/lib/trends/vidya/Vidya.Tests.cs +++ b/lib/trends/vidya/Vidya.Tests.cs @@ -1,111 +1,172 @@ -using QuanTAlib; +using System; +using System.Collections.Generic; using Xunit; -namespace Trends; +namespace QuanTAlib; public class VidyaTests { [Fact] - public void BasicCalculation() + public void BasicCalculation_DoesNotCrash() { - // Test with a small dataset - // Period = 2 - // Alpha = 2 / (2 + 1) = 0.666... - - var vidya = new Vidya(2); - - // Bar 1: Price 100 - // Init: PrevClose=100, LastVidya=100, Ups=[0,0], Downs=[0,0] - // Output: 100 - var v1 = vidya.Update(new TValue(DateTime.UtcNow, 100)); - Assert.Equal(100, v1.Value); - - // Bar 2: Price 110 - // Change = 110 - 100 = 10 - // Up=10, Down=0 - // Ups=[10,0], Downs=[0,0] - // SumUp=10, SumDown=0, Sum=10 - // VI = |10-0|/10 = 1 - // DynAlpha = 0.666 * 1 = 0.666 - // Vidya = 0.666 * 110 + 0.333 * 100 = 73.33 + 33.33 = 106.66 - var v2 = vidya.Update(new TValue(DateTime.UtcNow, 110)); - Assert.Equal(106.66666666666667, v2.Value, 5); - - // Bar 3: Price 105 - // Change = 105 - 110 = -5 - // Up=0, Down=5 - // Ups=[0,10], Downs=[5,0] (Circular buffer logic) - // SumUp=10, SumDown=5, Sum=15 - // VI = |10-5|/15 = 5/15 = 0.333 - // DynAlpha = 0.666 * 0.333 = 0.222 - // Vidya = 0.222 * 105 + 0.777 * 106.66 = 23.33 + 82.96 = 106.29 - var v3 = vidya.Update(new TValue(DateTime.UtcNow, 105)); - Assert.Equal(106.29629629629629, v3.Value, 5); + var vidya = new Vidya(10); + var gbm = new GBM(); + var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < bars.Count; i++) + { + vidya.Update(new TValue(bars[i].Time, bars[i].Close)); + } + + Assert.True(double.IsFinite(vidya.Last.Value)); } [Fact] - public void IsNewConsistency() + public void IsNew_Consistency() { - var vidya = new Vidya(5); - var inputs = new double[] { 100, 105, 102, 108, 110, 105 }; - - // Feed normally - var expected = new List(); - foreach (var input in inputs) + var vidya = new Vidya(10); + var gbm = new GBM(); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // Feed first 99 + for (int i = 0; i < 99; i++) { - expected.Add(vidya.Update(new TValue(DateTime.UtcNow, input)).Value); + vidya.Update(new TValue(bars[i].Time, bars[i].Close)); } - - // Feed with updates + + // Update with 100th point (isNew=true) + vidya.Update(new TValue(bars[99].Time, bars[99].Close), true); + + // Update with modified 100th point (isNew=false) + var val2 = vidya.Update(new TValue(bars[99].Time, bars[99].Close + 1.0), false); + + // Create new instance and feed up to modified + var vidya2 = new Vidya(10); + for (int i = 0; i < 99; i++) + { + vidya2.Update(new TValue(bars[i].Time, bars[i].Close)); + } + var val3 = vidya2.Update(new TValue(bars[99].Time, bars[99].Close + 1.0), true); + + Assert.Equal(val3.Value, val2.Value, 1e-9); + } + + [Fact] + public void Reset_Works() + { + var vidya = new Vidya(10); + var gbm = new GBM(); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < bars.Count; i++) + { + vidya.Update(new TValue(bars[i].Time, bars[i].Close)); + } + vidya.Reset(); - for (int i = 0; i < inputs.Length; i++) + Assert.Equal(0, vidya.Last.Value); + + // Feed again + for (int i = 0; i < bars.Count; i++) { - // Update with a temporary value first - vidya.Update(new TValue(DateTime.UtcNow, inputs[i] + 1), true); - - // Correct it - var corrected = vidya.Update(new TValue(DateTime.UtcNow, inputs[i]), false); - - Assert.Equal(expected[i], corrected.Value, 1e-9); + vidya.Update(new TValue(bars[i].Time, bars[i].Close)); + } + + Assert.True(double.IsFinite(vidya.Last.Value)); + } + + [Fact] + public void TSeries_Update_Matches_Streaming() + { + var vidya = new Vidya(10); + var gbm = new GBM(); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; + + var streamingResults = new List(); + for (int i = 0; i < series.Count; i++) + { + streamingResults.Add(vidya.Update(series[i]).Value); + } + + var vidya2 = new Vidya(10); + var seriesResults = vidya2.Update(series); + + Assert.Equal(streamingResults.Count, seriesResults.Count); + for (int i = 0; i < seriesResults.Count; i++) + { + Assert.Equal(streamingResults[i], seriesResults.Values[i], 1e-9); + } + } + + [Fact] + public void StaticCalculate_Matches_Streaming() + { + var gbm = new GBM(); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; + + var vidya = new Vidya(10); + var streamingResults = new List(); + for (int i = 0; i < series.Count; i++) + { + streamingResults.Add(vidya.Update(series[i]).Value); + } + + var staticResults = Vidya.Calculate(series, 10); + + Assert.Equal(streamingResults.Count, staticResults.Count); + for (int i = 0; i < staticResults.Count; i++) + { + Assert.Equal(streamingResults[i], staticResults.Values[i], 1e-9); } } [Fact] - public void StaticVsInstance() + public void StaticCalculateSpan_Matches_Streaming() { - var vidya = new Vidya(5); - var inputs = new double[] { 100, 105, 102, 108, 110, 105, 100, 95, 98, 102 }; - var tSeries = new TSeries(); - tSeries.Add(inputs); + var gbm = new GBM(); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; - var instanceResult = vidya.Update(tSeries); - - var staticResult = new double[inputs.Length]; - Vidya.Calculate(inputs, staticResult, 5); - - for (int i = 0; i < inputs.Length; i++) + var vidya = new Vidya(10); + var streamingResults = new List(); + for (int i = 0; i < series.Count; i++) { - Assert.Equal(instanceResult.Values[i], staticResult[i], 1e-9); + streamingResults.Add(vidya.Update(series[i]).Value); + } + + var spanResults = new double[series.Count]; + Vidya.Calculate(series.Values, spanResults, 10); + + for (int i = 0; i < spanResults.Length; i++) + { + Assert.Equal(streamingResults[i], spanResults[i], 1e-9); } } [Fact] - public void EdgeCases() + public void Chainability_Works() { - var vidya = new Vidya(5); + var vidya = new Vidya(10); + var gbm = new GBM(); + var bars = gbm.Fetch(10, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; - // Empty - Assert.Empty(vidya.Update(new TSeries())); + // Test TSeries chain + var result = vidya.Update(series); + Assert.NotNull(result); + Assert.IsType(result); - // NaN handling - vidya.Reset(); - vidya.Update(new TValue(DateTime.UtcNow, 100)); - var v2 = vidya.Update(new TValue(DateTime.UtcNow, double.NaN)); - Assert.Equal(100, v2.Value); // Should hold previous value - - // Period 1 - var vidya1 = new Vidya(1); - var v = vidya1.Update(new TValue(DateTime.UtcNow, 100)); - Assert.Equal(100, v.Value); + // Test TValue chain + var result2 = vidya.Update(series[0]); + Assert.IsType(result2); + } + + [Fact] + public void Constructor_InvalidParameters_ThrowsArgumentException() + { + Assert.Throws(() => new Vidya(0)); + Assert.Throws(() => new Vidya(-1)); } } diff --git a/lib/trends/vidya/Vidya.cs b/lib/trends/vidya/Vidya.cs index 301b0d3b..17a0c40f 100644 --- a/lib/trends/vidya/Vidya.cs +++ b/lib/trends/vidya/Vidya.cs @@ -166,6 +166,13 @@ public sealed class Vidya : ITValuePublisher return new TSeries(t, v); } + + public static TSeries Calculate(TSeries source, int period) + { + var vidya = new Vidya(period); + return vidya.Update(source); + } + /// /// Calculates VIDYA for the entire series. /// diff --git a/lib/trends/wma/Wma.Tests.cs b/lib/trends/wma/Wma.Tests.cs index 8a3e7317..ac0fb54c 100644 --- a/lib/trends/wma/Wma.Tests.cs +++ b/lib/trends/wma/Wma.Tests.cs @@ -1,572 +1,173 @@ -namespace QuanTAlib.Tests; +using System; +using System.Collections.Generic; +using Xunit; + +namespace QuanTAlib; -#pragma warning disable S2245 // Random is acceptable for simulation/testing purposes public class WmaTests { [Fact] - public void Wma_Constructor_ValidatesInput() + public void BasicCalculation_DoesNotCrash() + { + var wma = new Wma(10); + var gbm = new GBM(); + var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < bars.Count; i++) + { + wma.Update(new TValue(bars[i].Time, bars[i].Close)); + } + + Assert.True(double.IsFinite(wma.Last.Value)); + } + + [Fact] + public void IsNew_Consistency() + { + var wma = new Wma(10); + var gbm = new GBM(); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // Feed first 99 + for (int i = 0; i < 99; i++) + { + wma.Update(new TValue(bars[i].Time, bars[i].Close)); + } + + // Update with 100th point (isNew=true) + wma.Update(new TValue(bars[99].Time, bars[99].Close), true); + + // Update with modified 100th point (isNew=false) + var val2 = wma.Update(new TValue(bars[99].Time, bars[99].Close + 1.0), false); + + // Create new instance and feed up to modified + var wma2 = new Wma(10); + for (int i = 0; i < 99; i++) + { + wma2.Update(new TValue(bars[i].Time, bars[i].Close)); + } + var val3 = wma2.Update(new TValue(bars[99].Time, bars[99].Close + 1.0), true); + + Assert.Equal(val3.Value, val2.Value, 1e-9); + } + + [Fact] + public void Reset_Works() + { + var wma = new Wma(10); + var gbm = new GBM(); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < bars.Count; i++) + { + wma.Update(new TValue(bars[i].Time, bars[i].Close)); + } + + wma.Reset(); + Assert.Equal(0, wma.Last.Value); + Assert.False(wma.IsHot); + + // Feed again + for (int i = 0; i < bars.Count; i++) + { + wma.Update(new TValue(bars[i].Time, bars[i].Close)); + } + + Assert.True(double.IsFinite(wma.Last.Value)); + } + + [Fact] + public void TSeries_Update_Matches_Streaming() + { + var wma = new Wma(10); + var gbm = new GBM(); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; + + var streamingResults = new List(); + for (int i = 0; i < series.Count; i++) + { + streamingResults.Add(wma.Update(series[i]).Value); + } + + var wma2 = new Wma(10); + var seriesResults = wma2.Update(series); + + Assert.Equal(streamingResults.Count, seriesResults.Count); + for (int i = 0; i < seriesResults.Count; i++) + { + Assert.Equal(streamingResults[i], seriesResults.Values[i], 1e-9); + } + } + + [Fact] + public void StaticCalculate_Matches_Streaming() + { + var gbm = new GBM(); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; + + var wma = new Wma(10); + var streamingResults = new List(); + for (int i = 0; i < series.Count; i++) + { + streamingResults.Add(wma.Update(series[i]).Value); + } + + var staticResults = Wma.Calculate(series, 10); + + Assert.Equal(streamingResults.Count, staticResults.Count); + for (int i = 0; i < staticResults.Count; i++) + { + Assert.Equal(streamingResults[i], staticResults.Values[i], 1e-9); + } + } + + [Fact] + public void StaticCalculateSpan_Matches_Streaming() + { + var gbm = new GBM(); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; + + var wma = new Wma(10); + var streamingResults = new List(); + for (int i = 0; i < series.Count; i++) + { + streamingResults.Add(wma.Update(series[i]).Value); + } + + var spanResults = new double[series.Count]; + Wma.Calculate(series.Values, spanResults, 10); + + for (int i = 0; i < spanResults.Length; i++) + { + Assert.Equal(streamingResults[i], spanResults[i], 1e-9); + } + } + + [Fact] + public void Chainability_Works() + { + var wma = new Wma(10); + var gbm = new GBM(); + var bars = gbm.Fetch(10, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; + + // Test TSeries chain + var result = wma.Update(series); + Assert.NotNull(result); + Assert.IsType(result); + + // Test TValue chain + var result2 = wma.Update(series[0]); + Assert.IsType(result2); + } + + [Fact] + public void Constructor_InvalidParameters_ThrowsArgumentException() { Assert.Throws(() => new Wma(0)); Assert.Throws(() => new Wma(-1)); - - var wma = new Wma(10); - Assert.NotNull(wma); - } - - [Fact] - public void Wma_Calc_ReturnsValue() - { - var wma = new Wma(10); - - Assert.Equal(0, wma.Last.Value); - - TValue result = wma.Update(new TValue(DateTime.UtcNow, 100)); - - Assert.True(result.Value > 0); - Assert.Equal(result.Value, wma.Last.Value); - } - - [Fact] - public void Wma_FirstValue_ReturnsItself() - { - var wma = new Wma(10); - - TValue result = wma.Update(new TValue(DateTime.UtcNow, 100)); - - Assert.Equal(100.0, result.Value, 1e-10); - } - - [Fact] - public void Wma_Calc_IsNew_AcceptsParameter() - { - var wma = new Wma(10); - - wma.Update(new TValue(DateTime.UtcNow, 100), isNew: true); - double value1 = wma.Last.Value; - - wma.Update(new TValue(DateTime.UtcNow, 200), isNew: true); - double value2 = wma.Last.Value; - - // Values should change with new bars - Assert.NotEqual(value1, value2); - } - - [Fact] - public void Wma_Calc_IsNew_False_UpdatesValue() - { - var wma = new Wma(10); - - wma.Update(new TValue(DateTime.UtcNow, 100)); - wma.Update(new TValue(DateTime.UtcNow, 110), isNew: true); - double beforeUpdate = wma.Last.Value; - - wma.Update(new TValue(DateTime.UtcNow, 120), isNew: false); - double afterUpdate = wma.Last.Value; - - // Update should change the value - Assert.NotEqual(beforeUpdate, afterUpdate); - } - - [Fact] - public void Wma_Reset_ClearsState() - { - var wma = new Wma(10); - - wma.Update(new TValue(DateTime.UtcNow, 100)); - wma.Update(new TValue(DateTime.UtcNow, 105)); - double valueBefore = wma.Last.Value; - - wma.Reset(); - - Assert.Equal(0, wma.Last.Value); - - // After reset, should accept new values - wma.Update(new TValue(DateTime.UtcNow, 50)); - Assert.NotEqual(0, wma.Last.Value); - Assert.NotEqual(valueBefore, wma.Last.Value); - } - - [Fact] - public void Wma_Properties_Accessible() - { - var wma = new Wma(10); - - Assert.Equal(0, wma.Last.Value); - Assert.False(wma.IsHot); - - wma.Update(new TValue(DateTime.UtcNow, 100)); - - Assert.NotEqual(0, wma.Last.Value); - } - - [Fact] - public void Wma_IsHot_BecomesTrueWhenBufferFull() - { - var wma = new Wma(5); - - Assert.False(wma.IsHot); - - for (int i = 1; i <= 4; i++) - { - wma.Update(new TValue(DateTime.UtcNow, i * 10)); - Assert.False(wma.IsHot); - } - - wma.Update(new TValue(DateTime.UtcNow, 50)); - Assert.True(wma.IsHot); - } - - [Fact] - public void Wma_CalculatesCorrectWeightedAverage() - { - var wma = new Wma(5); - - wma.Update(new TValue(DateTime.UtcNow, 10)); - wma.Update(new TValue(DateTime.UtcNow, 20)); - wma.Update(new TValue(DateTime.UtcNow, 30)); - wma.Update(new TValue(DateTime.UtcNow, 40)); - wma.Update(new TValue(DateTime.UtcNow, 50)); - - // WMA(5) of 10,20,30,40,50 = (1*10 + 2*20 + 3*30 + 4*40 + 5*50) / 15 - // = (10 + 40 + 90 + 160 + 250) / 15 = 550 / 15 = 36.666... - Assert.Equal(550.0 / 15.0, wma.Last.Value, 1e-10); - } - - [Fact] - public void Wma_SlidingWindow_Works() - { - var wma = new Wma(3); - - wma.Update(new TValue(DateTime.UtcNow, 10)); - wma.Update(new TValue(DateTime.UtcNow, 20)); - wma.Update(new TValue(DateTime.UtcNow, 30)); - - // WMA(3) of 10,20,30 = (1*10 + 2*20 + 3*30) / 6 = (10 + 40 + 90) / 6 = 140/6 = 23.333... - Assert.Equal(140.0 / 6.0, wma.Last.Value, 1e-10); - - wma.Update(new TValue(DateTime.UtcNow, 40)); - - // WMA(3) of 20,30,40 = (1*20 + 2*30 + 3*40) / 6 = (20 + 60 + 120) / 6 = 200/6 = 33.333... - Assert.Equal(200.0 / 6.0, wma.Last.Value, 1e-10); - - wma.Update(new TValue(DateTime.UtcNow, 50)); - - // WMA(3) of 30,40,50 = (1*30 + 2*40 + 3*50) / 6 = (30 + 80 + 150) / 6 = 260/6 = 43.333... - Assert.Equal(260.0 / 6.0, wma.Last.Value, 1e-10); - } - - [Fact] - public void Wma_IterativeCorrections_RestoreToOriginalState() - { - var wma = new Wma(5); - var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1); - - // Feed 10 new values - TValue tenthInput = default; - for (int i = 0; i < 10; i++) - { - var bar = gbm.Next(isNew: true); - tenthInput = new TValue(bar.Time, bar.Close); - wma.Update(tenthInput, isNew: true); - } - - // Remember WMA state after 10 values - double wmaAfterTen = wma.Last.Value; - - // Generate 9 corrections with isNew=false (different values) - for (int i = 0; i < 9; i++) - { - var bar = gbm.Next(isNew: false); - wma.Update(new TValue(bar.Time, bar.Close), isNew: false); - } - - // Feed the remembered 10th input again with isNew=false - TValue finalWma = wma.Update(tenthInput, isNew: false); - - // WMA should match the original state after 10 values - Assert.Equal(wmaAfterTen, finalWma.Value, 1e-10); - } - - [Fact] - public void Wma_BatchCalc_MatchesIterativeCalc() - { - var wmaIterative = new Wma(10); - var wmaBatch = new Wma(10); - var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1); - - // Generate data - var series = new TSeries(); - for (int i = 0; i < 100; i++) - { - var bar = gbm.Next(isNew: true); - series.Add(bar.Time, bar.Close); - } - - Assert.True(series.Count > 0); - - // Calculate iteratively - var iterativeResults = new TSeries(); - foreach (var item in series) - { - iterativeResults.Add(wmaIterative.Update(item)); - } - - // Calculate batch - var batchResults = wmaBatch.Update(series); - - // Compare - Assert.Equal(iterativeResults.Count, batchResults.Count); - for (int i = 0; i < iterativeResults.Count; i++) - { - Assert.Equal(iterativeResults[i].Value, batchResults[i].Value, 1e-10); - Assert.Equal(iterativeResults[i].Time, batchResults[i].Time); - } - } - - [Fact] - public void Wma_Result_ImplicitConversionToDouble() - { - var wma = new Wma(10); - wma.Update(new TValue(DateTime.UtcNow, 100)); - - // This should compile and work because TValue has implicit conversion to double - double result = wma.Last.Value; - - Assert.Equal(100.0, result, 1e-10); - } - - [Fact] - public void Wma_NaN_Input_UsesLastValidValue() - { - var wma = new Wma(5); - - // Feed some valid values - wma.Update(new TValue(DateTime.UtcNow, 100)); - wma.Update(new TValue(DateTime.UtcNow, 110)); - - // Feed NaN - should use last valid value (110) - var resultAfterNaN = wma.Update(new TValue(DateTime.UtcNow, double.NaN)); - - // Result should be finite (not NaN) - Assert.True(double.IsFinite(resultAfterNaN.Value)); - Assert.NotEqual(0, resultAfterNaN.Value); - } - - [Fact] - public void Wma_Infinity_Input_UsesLastValidValue() - { - var wma = new Wma(5); - - // Feed some valid values - wma.Update(new TValue(DateTime.UtcNow, 100)); - wma.Update(new TValue(DateTime.UtcNow, 110)); - - // Feed positive infinity - should use last valid value - var resultAfterPosInf = wma.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity)); - Assert.True(double.IsFinite(resultAfterPosInf.Value)); - - // Feed negative infinity - should use last valid value - var resultAfterNegInf = wma.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity)); - Assert.True(double.IsFinite(resultAfterNegInf.Value)); - } - - [Fact] - public void Wma_MultipleNaN_ContinuesWithLastValid() - { - var wma = new Wma(5); - - // Feed valid values - wma.Update(new TValue(DateTime.UtcNow, 100)); - wma.Update(new TValue(DateTime.UtcNow, 110)); - wma.Update(new TValue(DateTime.UtcNow, 120)); - - // Feed multiple NaN values - var r1 = wma.Update(new TValue(DateTime.UtcNow, double.NaN)); - var r2 = wma.Update(new TValue(DateTime.UtcNow, double.NaN)); - var r3 = wma.Update(new TValue(DateTime.UtcNow, double.NaN)); - - // All results should be finite - Assert.True(double.IsFinite(r1.Value)); - Assert.True(double.IsFinite(r2.Value)); - Assert.True(double.IsFinite(r3.Value)); - } - - [Fact] - public void Wma_BatchCalc_HandlesNaN() - { - var wma = new Wma(5); - - // Create series with NaN values interspersed - var series = new TSeries(); - series.Add(DateTime.UtcNow.Ticks, 100); - series.Add(DateTime.UtcNow.Ticks + 1, 110); - series.Add(DateTime.UtcNow.Ticks + 2, double.NaN); - series.Add(DateTime.UtcNow.Ticks + 3, 120); - series.Add(DateTime.UtcNow.Ticks + 4, double.PositiveInfinity); - series.Add(DateTime.UtcNow.Ticks + 5, 130); - - var results = wma.Update(series); - - // All results should be finite - foreach (var result in results) - { - Assert.True(double.IsFinite(result.Value), $"Expected finite value but got {result.Value}"); - } - } - - [Fact] - public void Wma_Reset_ClearsLastValidValue() - { - var wma = new Wma(5); - - // Feed values including NaN - wma.Update(new TValue(DateTime.UtcNow, 100)); - wma.Update(new TValue(DateTime.UtcNow, double.NaN)); - - // Reset - wma.Reset(); - - // After reset, first valid value should establish new baseline - var result = wma.Update(new TValue(DateTime.UtcNow, 50)); - Assert.Equal(50.0, result.Value, 1e-10); - } - - [Fact] - public void Wma_StaticCalculate_Works() - { - var series = new TSeries(); - series.Add(DateTime.UtcNow.Ticks, 10); - series.Add(DateTime.UtcNow.Ticks + 1, 20); - series.Add(DateTime.UtcNow.Ticks + 2, 30); - series.Add(DateTime.UtcNow.Ticks + 3, 40); - series.Add(DateTime.UtcNow.Ticks + 4, 50); - - var results = Wma.Calculate(series, 3); - - Assert.Equal(5, results.Count); - // WMA(3) for last 3 values [30,40,50]: (1*30 + 2*40 + 3*50) / 6 = 260/6 = 43.333... - Assert.Equal(260.0 / 6.0, results.Last.Value, 1e-10); - } - - [Fact] - public void Wma_Period1_ReturnsInputValues() - { - var wma = new Wma(1); - - Assert.Equal(100.0, wma.Update(new TValue(DateTime.UtcNow, 100)).Value, 1e-10); - Assert.Equal(200.0, wma.Update(new TValue(DateTime.UtcNow, 200)).Value, 1e-10); - Assert.Equal(150.0, wma.Update(new TValue(DateTime.UtcNow, 150)).Value, 1e-10); - } - - [Fact] - public void Wma_MoreWeightOnRecentValues() - { - var wma = new Wma(3); - var sma = new Sma(3); - - // Feed same values to both - wma.Update(new TValue(DateTime.UtcNow, 10)); - sma.Update(new TValue(DateTime.UtcNow, 10)); - wma.Update(new TValue(DateTime.UtcNow, 20)); - sma.Update(new TValue(DateTime.UtcNow, 20)); - wma.Update(new TValue(DateTime.UtcNow, 100)); // High recent value - sma.Update(new TValue(DateTime.UtcNow, 100)); - - // WMA should be higher than SMA because it weights the high recent value more - // SMA = (10 + 20 + 100) / 3 = 43.333... - // WMA = (1*10 + 2*20 + 3*100) / 6 = (10 + 40 + 300) / 6 = 58.333... - Assert.True(wma.Last.Value > sma.Last.Value); - Assert.Equal(350.0 / 6.0, wma.Last.Value, 1e-10); - Assert.Equal(130.0 / 3.0, sma.Last.Value, 1e-10); - } - - [Fact] - public void Wma_WarmupDivisor_CalculatedCorrectly() - { - var wma = new Wma(5); - - // First value: divisor = 1*(1+1)/2 = 1 - var r1 = wma.Update(new TValue(DateTime.UtcNow, 100)); - Assert.Equal(100.0, r1.Value, 1e-10); - - // Second value: divisor = 2*(2+1)/2 = 3, wsum = 1*100 + 2*200 = 500 - var r2 = wma.Update(new TValue(DateTime.UtcNow, 200)); - Assert.Equal(500.0 / 3.0, r2.Value, 1e-10); - - // Third value: divisor = 3*(3+1)/2 = 6, wsum = 1*100 + 2*200 + 3*300 = 1400 - var r3 = wma.Update(new TValue(DateTime.UtcNow, 300)); - Assert.Equal(1400.0 / 6.0, r3.Value, 1e-10); - } - - // ============== Span API Tests ============== - - [Fact] - public void Wma_SpanCalc_ValidatesInput() - { - double[] source = [1, 2, 3, 4, 5]; - double[] output = new double[5]; - double[] wrongSizeOutput = new double[3]; - - // Period must be > 0 - Assert.Throws(() => Wma.Calculate(source.AsSpan(), output.AsSpan(), 0)); - Assert.Throws(() => Wma.Calculate(source.AsSpan(), output.AsSpan(), -1)); - - // Output must be same length as source - Assert.Throws(() => Wma.Calculate(source.AsSpan(), wrongSizeOutput.AsSpan(), 3)); - } - - [Fact] - public void Wma_SpanCalc_MatchesTSeriesCalc() - { - var series = new TSeries(); - double[] source = new double[100]; - double[] output = new double[100]; - - var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42); - for (int i = 0; i < 100; i++) - { - var bar = gbm.Next(isNew: true); - source[i] = bar.Close; - series.Add(bar.Time, bar.Close); - } - - // Calculate with TSeries API - var tseriesResult = Wma.Calculate(series, 10); - - // Calculate with Span API - Wma.Calculate(source.AsSpan(), output.AsSpan(), 10); - - // Compare results - for (int i = 0; i < 100; i++) - { - Assert.Equal(tseriesResult[i].Value, output[i], 1e-10); - } - } - - [Fact] - public void Wma_SpanCalc_CalculatesCorrectly() - { - double[] source = [10, 20, 30, 40, 50]; - double[] output = new double[5]; - - Wma.Calculate(source.AsSpan(), output.AsSpan(), 3); - - // WMA(3) warmup: - // i=0: 10 (1*10 / 1) - // i=1: (1*10 + 2*20) / 3 = 50/3 = 16.666... - // i=2: (1*10 + 2*20 + 3*30) / 6 = 140/6 = 23.333... - // i=3: sliding: (1*20 + 2*30 + 3*40) / 6 = 200/6 = 33.333... - // i=4: (1*30 + 2*40 + 3*50) / 6 = 260/6 = 43.333... - Assert.Equal(10.0, output[0], 1e-10); - Assert.Equal(50.0 / 3.0, output[1], 1e-10); - Assert.Equal(140.0 / 6.0, output[2], 1e-10); - Assert.Equal(200.0 / 6.0, output[3], 1e-10); - Assert.Equal(260.0 / 6.0, output[4], 1e-10); - } - - [Fact] - public void Wma_SpanCalc_ZeroAllocation() - { - double[] source = new double[10000]; - double[] output = new double[10000]; - var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42); - for (int i = 0; i < source.Length; i++) - source[i] = gbm.Next().Close; - - // Warm up - Wma.Calculate(source.AsSpan(), output.AsSpan(), 100); - - // This test verifies the method runs without throwing - Assert.True(double.IsFinite(output[^1])); - } - - [Fact] - public void Wma_SpanCalc_HandlesNaN() - { - double[] source = [100, 110, double.NaN, 120, 130]; - double[] output = new double[5]; - - Wma.Calculate(source.AsSpan(), output.AsSpan(), 3); - - // All outputs should be finite - foreach (var val in output) - { - Assert.True(double.IsFinite(val), $"Expected finite value but got {val}"); - } - } - - [Fact] - public void Wma_SpanCalc_Period1_ReturnsInput() - { - double[] source = [10, 20, 30, 40, 50]; - double[] output = new double[5]; - - Wma.Calculate(source.AsSpan(), output.AsSpan(), 1); - - for (int i = 0; i < source.Length; i++) - { - Assert.Equal(source[i], output[i], 1e-10); - } - } - - [Fact] - public void Wma_SpanCalc_UsesStackallocForSmallPeriods() - { - double[] source = new double[1000]; - double[] output = new double[1000]; - var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42); - for (int i = 0; i < source.Length; i++) - source[i] = gbm.Next().Close; - - // Period <= 512 uses stackalloc - Wma.Calculate(source.AsSpan(), output.AsSpan(), 100); - Assert.True(double.IsFinite(output[^1])); - - // Period > 512 uses heap allocation - double[] output2 = new double[1000]; - Wma.Calculate(source.AsSpan(), output2.AsSpan(), 600); - Assert.True(double.IsFinite(output2[^1])); - } - [Fact] - public void Wma_AllModes_ProduceSameResult() - { - // Arrange - int period = 10; - var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123); - var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); - var series = bars.Close; - - // 1. Batch Mode - var batchSeries = Wma.Calculate(series, period); - double expected = batchSeries.Last.Value; - - // 2. Span Mode - var tValues = series.Values.ToArray(); - var spanInput = new ReadOnlySpan(tValues); - var spanOutput = new double[tValues.Length]; - Wma.Calculate(spanInput, spanOutput, period); - double spanResult = spanOutput[^1]; - - // 3. Streaming Mode - var streamingInd = new Wma(period); - for (int i = 0; i < series.Count; i++) - { - streamingInd.Update(series[i]); - } - double streamingResult = streamingInd.Last.Value; - - // 4. Eventing Mode - var pubSource = new TSeries(); - var eventingInd = new Wma(pubSource, period); - for (int i = 0; i < series.Count; i++) - { - pubSource.Add(series[i]); - } - double eventingResult = eventingInd.Last.Value; - - // Assert - Assert.Equal(expected, spanResult, precision: 9); - Assert.Equal(expected, streamingResult, precision: 9); - Assert.Equal(expected, eventingResult, precision: 9); } }