diff --git a/.clinerules/testprotocol.md b/.clinerules/testprotocol.md new file mode 100644 index 00000000..687bceb6 --- /dev/null +++ b/.clinerules/testprotocol.md @@ -0,0 +1,1300 @@ +# QuanTAlib Indicator Test Protocol + +> **Comprehensive Testing Requirements for All Indicators** + +This document defines the **mandatory** and **recommended** tests that every indicator in QuanTAlib must implement. Adherence to this protocol ensures correctness, consistency, robustness, and maintainability across the entire library. + +## File Structure + +Every indicator requires the following test files: + +| File | Purpose | Mandatory | +|------------------------------|---------------------------------------------|-----------| +| `[Name].Tests.cs` | Unit tests for core functionality | ✅ Yes | +| `[Name].Validation.Tests.cs` | Cross-validation against external libraries | ✅ Yes | +| `[Name].Quantower.Tests.cs` | Quantower adapter integration tests | ✅ Yes | + +## 1. Unit Tests (`[Name].Tests.cs`) + +Unit tests verify the internal logic, state management, API contracts, and edge case handling of the indicator. + +### 1.1 Constructor & Parameter Validation + +Every indicator must validate its constructor parameters. + +#### Required Tests + +| Test Name | Description | Priority | +|-------------------------------------|-----------------------------------------------------------------|-------------| +| `Constructor_ValidatesInput` | Verify invalid primary parameters throw `ArgumentException` | 🔴 Critical | +| `Constructor_ValidatesOptionalArgs` | Verify invalid optional parameters throw appropriate exceptions | 🟡 Required | +| `Constructor_ValidBoundaryValues` | Verify minimum valid values are accepted | 🟡 Required | + +#### Implementation Pattern + +```csharp +[Fact] +public void Constructor_ValidatesInput() +{ + // Period-based indicators + Assert.Throws(() => new Sma(0)); + Assert.Throws(() => new Sma(-1)); + + // Valid construction + var sma = new Sma(10); + Assert.NotNull(sma); +} + +[Fact] +public void Constructor_ValidatesOptionalArgs() +{ + // For EMA with alpha parameter + Assert.Throws(() => new Ema(0.0)); // alpha must be > 0 + Assert.Throws(() => new Ema(-0.1)); // alpha must be positive + Assert.Throws(() => new Ema(1.1)); // alpha must be <= 1 + + var ema = new Ema(0.5); + Assert.NotNull(ema); +} + +[Fact] +public void Constructor_ValidatesRelatedParameters() +{ + // For KAMA with fast/slow periods + Assert.Throws(() => new Kama(10, fastPeriod: 10, slowPeriod: 5)); // fast >= slow + Assert.Throws(() => new Kama(10, fastPeriod: 0)); + Assert.Throws(() => new Kama(10, slowPeriod: 0)); +} +``` + +### 1.2 Basic Functionality + +#### Required Tests + +| Test Name | Description | Priority | +|------------------------------|-----------------------------------------------------------|--------------| +| `Calc_ReturnsValue` | Verify `Update` returns valid `TValue` and updates `Last` | 🔴 Critical | +| `FirstValue_ReturnsExpected` | Verify first output value is correct (often equals input) | 🟡 Required | +| `Properties_Accessible` | Verify `Last`, `IsHot`, `Name` are accessible | 🟡 Required | +| `CalculatesCorrectValue` | Verify calculation against known mathematical result | 🔴 Critical | + +#### Implementation Pattern + +```csharp +[Fact] +public void Calc_ReturnsValue() +{ + var sma = new Sma(10); + + Assert.Equal(0, sma.Last.Value); // Initial value + + TValue result = sma.Update(new TValue(DateTime.UtcNow, 100)); + + Assert.True(result.Value > 0); + Assert.Equal(result.Value, sma.Last.Value); +} + +[Fact] +public void FirstValue_ReturnsItself() +{ + var sma = new Sma(10); + TValue result = sma.Update(new TValue(DateTime.UtcNow, 100)); + Assert.Equal(100.0, result.Value, 1e-10); +} + +[Fact] +public void Properties_Accessible() +{ + var sma = new Sma(10); + + Assert.Equal(0, sma.Last.Value); + Assert.False(sma.IsHot); + Assert.Contains("Sma", sma.Name); + + sma.Update(new TValue(DateTime.UtcNow, 100)); + Assert.NotEqual(0, sma.Last.Value); +} + +[Fact] +public void CalculatesCorrectAverage() +{ + var sma = new Sma(5); + + sma.Update(new TValue(DateTime.UtcNow, 10)); + sma.Update(new TValue(DateTime.UtcNow, 20)); + sma.Update(new TValue(DateTime.UtcNow, 30)); + sma.Update(new TValue(DateTime.UtcNow, 40)); + sma.Update(new TValue(DateTime.UtcNow, 50)); + + // SMA(5) of 10,20,30,40,50 = 150/5 = 30 + Assert.Equal(30.0, sma.Last.Value, 1e-10); +} +``` + +### 1.3 State Management & Bar Correction + +Bar correction is critical for real-time trading applications where the current bar updates continuously. + +#### Required Tests + +| Test Name | Description | Priority | +|-----------|-------------|----------| +| `Calc_IsNew_AcceptsParameter` | Verify `isNew: true` advances state | 🔴 Critical | +| `Calc_IsNew_False_UpdatesValue` | Verify `isNew: false` updates without advancing | 🔴 Critical | +| `IterativeCorrections_RestoreToOriginalState` | Verify state restoration after corrections | 🔴 Critical | +| `Reset_ClearsState` | Verify `Reset()` restores to initial state | 🔴 Critical | +| `Reset_ClearsLastValidValue` | Verify NaN tracking is also reset | 🟡 Required | + +#### Implementation Pattern + +```csharp +[Fact] +public void Calc_IsNew_AcceptsParameter() +{ + var sma = new Sma(10); + + sma.Update(new TValue(DateTime.UtcNow, 100), isNew: true); + double value1 = sma.Last.Value; + + sma.Update(new TValue(DateTime.UtcNow, 200), isNew: true); + double value2 = sma.Last.Value; + + Assert.NotEqual(value1, value2); +} + +[Fact] +public void Calc_IsNew_False_UpdatesValue() +{ + var sma = new Sma(10); + + sma.Update(new TValue(DateTime.UtcNow, 100)); + sma.Update(new TValue(DateTime.UtcNow, 110), isNew: true); + double beforeUpdate = sma.Last.Value; + + sma.Update(new TValue(DateTime.UtcNow, 120), isNew: false); + double afterUpdate = sma.Last.Value; + + Assert.NotEqual(beforeUpdate, afterUpdate); +} + +[Fact] +public void IterativeCorrections_RestoreToOriginalState() +{ + var sma = new Sma(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); + sma.Update(tenthInput, isNew: true); + } + + // Remember state after 10 values + double stateAfterTen = sma.Last.Value; + + // Generate 9 corrections with isNew=false (different values) + for (int i = 0; i < 9; i++) + { + var bar = gbm.Next(isNew: false); + sma.Update(new TValue(bar.Time, bar.Close), isNew: false); + } + + // Feed the remembered 10th input again with isNew=false + TValue finalResult = sma.Update(tenthInput, isNew: false); + + // State should match the original state after 10 values + Assert.Equal(stateAfterTen, finalResult.Value, 1e-10); +} + +[Fact] +public void Reset_ClearsState() +{ + var sma = new Sma(10); + + sma.Update(new TValue(DateTime.UtcNow, 100)); + sma.Update(new TValue(DateTime.UtcNow, 105)); + double valueBefore = sma.Last.Value; + + sma.Reset(); + + Assert.Equal(0, sma.Last.Value); + Assert.False(sma.IsHot); + + // After reset, should accept new values + sma.Update(new TValue(DateTime.UtcNow, 50)); + Assert.NotEqual(0, sma.Last.Value); + Assert.NotEqual(valueBefore, sma.Last.Value); +} + +[Fact] +public void Reset_ClearsLastValidValue() +{ + var sma = new Sma(5); + + sma.Update(new TValue(DateTime.UtcNow, 100)); + sma.Update(new TValue(DateTime.UtcNow, double.NaN)); + + sma.Reset(); + + // After reset, first valid value should establish new baseline + var result = sma.Update(new TValue(DateTime.UtcNow, 50)); + Assert.Equal(50.0, result.Value, 1e-10); +} +``` + +### 1.4 Warmup & Convergence + +#### Required Tests + +| Test Name | Description | Priority | +|-----------|-------------|----------| +| `IsHot_BecomesTrueWhenBufferFull` | Verify warmup completion | 🔴 Critical | +| `IsHot_IsPeriodDependent` | Verify warmup scales with period | 🟡 Required | +| `WarmupPeriod_IsSetCorrectly` | Verify `WarmupPeriod` property | 🟡 Required | + +#### Implementation Pattern + +```csharp +[Fact] +public void IsHot_BecomesTrueWhenBufferFull() +{ + var sma = new Sma(5); + + Assert.False(sma.IsHot); + + for (int i = 1; i <= 4; i++) + { + sma.Update(new TValue(DateTime.UtcNow, i * 10)); + Assert.False(sma.IsHot); + } + + sma.Update(new TValue(DateTime.UtcNow, 50)); + Assert.True(sma.IsHot); +} + +[Fact] +public void IsHot_IsPeriodDependent() +{ + // For exponential indicators like EMA + int[] periods = [10, 20, 50, 100]; + int[] expectedSteps = new int[periods.Length]; + + for (int i = 0; i < periods.Length; i++) + { + int period = periods[i]; + var ema = new Ema(period); + + int steps = 0; + while (!ema.IsHot && steps < 500) + { + ema.Update(new TValue(DateTime.UtcNow, 100)); + steps++; + } + expectedSteps[i] = steps; + } + + // Verify warmup times increase with period + Assert.True(expectedSteps[0] < expectedSteps[1]); + Assert.True(expectedSteps[1] < expectedSteps[2]); + Assert.True(expectedSteps[2] < expectedSteps[3]); +} + +[Fact] +public void WarmupPeriod_IsSetCorrectly() +{ + var sma = new Sma(10); + Assert.Equal(10, sma.WarmupPeriod); +} +``` + +### 1.5 Robustness (NaN/Infinity Handling) + +All indicators must handle invalid inputs gracefully without crashing or propagating invalid values. + +#### Required Tests + +| Test Name | Description | Priority | +|-----------|-------------|----------| +| `NaN_Input_UsesLastValidValue` | Verify NaN substitution | 🔴 Critical | +| `Infinity_Input_UsesLastValidValue` | Verify Infinity handling | 🔴 Critical | +| `MultipleNaN_ContinuesWithLastValid` | Verify consecutive NaN handling | 🟡 Required | +| `BatchCalc_HandlesNaN` | Verify batch NaN handling | 🟡 Required | +| `AllNaN_ReturnsNaN` | Verify behavior with all-NaN input | 🟡 Required | + +#### Implementation Pattern + +```csharp +[Fact] +public void NaN_Input_UsesLastValidValue() +{ + var sma = new Sma(5); + + sma.Update(new TValue(DateTime.UtcNow, 100)); + sma.Update(new TValue(DateTime.UtcNow, 110)); + + var resultAfterNaN = sma.Update(new TValue(DateTime.UtcNow, double.NaN)); + + Assert.True(double.IsFinite(resultAfterNaN.Value)); + Assert.NotEqual(0, resultAfterNaN.Value); +} + +[Fact] +public void Infinity_Input_UsesLastValidValue() +{ + var sma = new Sma(5); + + sma.Update(new TValue(DateTime.UtcNow, 100)); + sma.Update(new TValue(DateTime.UtcNow, 110)); + + var resultAfterPosInf = sma.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity)); + Assert.True(double.IsFinite(resultAfterPosInf.Value)); + + var resultAfterNegInf = sma.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity)); + Assert.True(double.IsFinite(resultAfterNegInf.Value)); +} + +[Fact] +public void MultipleNaN_ContinuesWithLastValid() +{ + var sma = new Sma(5); + + sma.Update(new TValue(DateTime.UtcNow, 100)); + sma.Update(new TValue(DateTime.UtcNow, 110)); + sma.Update(new TValue(DateTime.UtcNow, 120)); + + var r1 = sma.Update(new TValue(DateTime.UtcNow, double.NaN)); + var r2 = sma.Update(new TValue(DateTime.UtcNow, double.NaN)); + var r3 = sma.Update(new TValue(DateTime.UtcNow, double.NaN)); + + Assert.True(double.IsFinite(r1.Value)); + Assert.True(double.IsFinite(r2.Value)); + Assert.True(double.IsFinite(r3.Value)); +} + +[Fact] +public void BatchCalc_HandlesNaN() +{ + var sma = new Sma(5); + + 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 = sma.Update(series); + + foreach (var result in results) + { + Assert.True(double.IsFinite(result.Value), + $"Expected finite value but got {result.Value}"); + } +} +``` + +### 1.6 Consistency Tests + +These tests ensure all API modes produce identical results. + +#### Required Tests + +| Test Name | Description | Priority | +|-----------|-------------|----------| +| `BatchCalc_MatchesIterativeCalc` | Verify TSeries batch matches streaming | 🔴 Critical | +| `AllModes_ProduceSameResult` | **Critical**: All 4 modes must match | 🔴 Critical | +| `StaticBatch_Works` | Verify static `Batch` method | 🟡 Required | + +#### Implementation Pattern + +```csharp +[Fact] +public void BatchCalc_MatchesIterativeCalc() +{ + var smaIterative = new Sma(10); + var smaBatch = new Sma(10); + var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1); + + var series = new TSeries(); + for (int i = 0; i < 100; i++) + { + var bar = gbm.Next(isNew: true); + series.Add(bar.Time, bar.Close); + } + + // Calculate iteratively + var iterativeResults = new TSeries(); + foreach (var item in series) + { + iterativeResults.Add(smaIterative.Update(item)); + } + + // Calculate batch + var batchResults = smaBatch.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 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 (static method) + var batchSeries = Sma.Batch(series, period); + double expected = batchSeries.Last.Value; + + // 2. Span Mode (static method with spans) + var tValues = series.Values.ToArray(); + var spanInput = new ReadOnlySpan(tValues); + var spanOutput = new double[tValues.Length]; + Sma.Batch(spanInput, spanOutput, period); + double spanResult = spanOutput[^1]; + + // 3. Streaming Mode (instance, one value at a time) + var streamingInd = new Sma(period); + for (int i = 0; i < series.Count; i++) + { + streamingInd.Update(series[i]); + } + double streamingResult = streamingInd.Last.Value; + + // 4. Eventing Mode (chained via ITValuePublisher) + var pubSource = new TSeries(); + var eventingInd = new Sma(pubSource, period); + for (int i = 0; i < series.Count; i++) + { + pubSource.Add(series[i]); + } + double eventingResult = eventingInd.Last.Value; + + // Assert all modes produce identical results + Assert.Equal(expected, spanResult, precision: 9); + Assert.Equal(expected, streamingResult, precision: 9); + Assert.Equal(expected, eventingResult, precision: 9); +} + +[Fact] +public void StaticBatch_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 = Sma.Batch(series, 3); + + Assert.Equal(5, results.Count); + Assert.Equal(40.0, results.Last.Value, 1e-10); +} +``` + +### 1.7 Span API Tests (High Performance) + +#### Required Tests + +| Test Name | Description | Priority | +|-----------|-------------|----------| +| `SpanBatch_ValidatesInput` | Verify buffer length validation | 🔴 Critical | +| `SpanBatch_MatchesTSeriesBatch` | Verify Span matches TSeries output | 🔴 Critical | +| `SpanBatch_CalculatesCorrectly` | Verify correct calculation with spans | 🟡 Required | +| `SpanBatch_ZeroAllocation` | Verify no stack overflow on large data | 🟡 Required | +| `SpanBatch_HandlesNaN` | Verify NaN handling in span mode | 🟡 Required | +| `SpanBatch_Period1_ReturnsInput` | Verify edge case period=1 | 🟢 Recommended | + +#### Implementation Pattern + +```csharp +[Fact] +public void SpanBatch_ValidatesInput() +{ + double[] source = [1, 2, 3, 4, 5]; + double[] output = new double[5]; + double[] wrongSizeOutput = new double[3]; + + // Period must be > 0 + Assert.Throws(() => + Sma.Batch(source.AsSpan(), output.AsSpan(), 0)); + Assert.Throws(() => + Sma.Batch(source.AsSpan(), output.AsSpan(), -1)); + + // Output must be same length as source + Assert.Throws(() => + Sma.Batch(source.AsSpan(), wrongSizeOutput.AsSpan(), 3)); +} + +[Fact] +public void SpanBatch_MatchesTSeriesBatch() +{ + 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); + } + + var tseriesResult = Sma.Batch(series, 10); + Sma.Batch(source.AsSpan(), output.AsSpan(), 10); + + for (int i = 0; i < 100; i++) + { + Assert.Equal(tseriesResult[i].Value, output[i], 1e-10); + } +} + +[Fact] +public void SpanBatch_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 + Sma.Batch(source.AsSpan(), output.AsSpan(), 100); + + // Verify method completes without OOM or stack overflow + Assert.True(double.IsFinite(output[^1])); +} + +[Fact] +public void SpanBatch_HandlesNaN() +{ + double[] source = [100, 110, double.NaN, 120, 130]; + double[] output = new double[5]; + + Sma.Batch(source.AsSpan(), output.AsSpan(), 3); + + foreach (var val in output) + { + Assert.True(double.IsFinite(val), $"Expected finite value but got {val}"); + } +} +``` + +### 1.8 Priming Tests + +For indicators that support pre-loading state with historical data. + +#### Required Tests (if indicator supports `Prime`) + +| Test Name | Description | Priority | +|-----------|-------------|----------| +| `Prime_SetsStateCorrectly` | Verify primed state matches streaming | 🟡 Required | +| `Prime_WithInsufficientHistory_IsNotHot` | Verify warmup with short history | 🟡 Required | +| `Prime_HandlesNaN_InHistory` | Verify NaN handling during prime | 🟡 Required | + +#### Implementation Pattern + +```csharp +[Fact] +public void Prime_SetsStateCorrectly() +{ + var sma = new Sma(5); + double[] history = [10, 20, 30, 40, 50]; // SMA(5) = 30 + + sma.Prime(history); + + Assert.True(sma.IsHot); + Assert.Equal(30.0, sma.Last.Value, 1e-10); + + // Verify it continues correctly + sma.Update(new TValue(DateTime.UtcNow, 60)); // 20,30,40,50,60 -> 40 + Assert.Equal(40.0, sma.Last.Value, 1e-10); +} + +[Fact] +public void Prime_WithInsufficientHistory_IsNotHot() +{ + var sma = new Sma(10); + double[] history = [10, 20, 30, 40, 50]; + + sma.Prime(history); + + Assert.False(sma.IsHot); + Assert.Equal(30.0, sma.Last.Value, 1e-10); // It calculates what it can +} + +[Fact] +public void Prime_HandlesNaN_InHistory() +{ + var sma = new Sma(3); + double[] history = [10, 20, double.NaN, 40]; + + sma.Prime(history); + + Assert.True(sma.IsHot); + Assert.True(double.IsFinite(sma.Last.Value)); +} +``` + +### 1.9 Calculate Method Tests + +For the static `Calculate` method that returns both results and a primed indicator. + +#### Required Tests (if indicator supports `Calculate`) + +| Test Name | Description | Priority | +|-----------|-------------|----------| +| `Calculate_ReturnsCorrectResultsAndHotIndicator` | Verify tuple return | 🟡 Required | + +#### Implementation Pattern + +```csharp +[Fact] +public void Calculate_ReturnsCorrectResultsAndHotIndicator() +{ + var series = new TSeries(); + for (int i = 1; i <= 10; i++) + series.Add(DateTime.UtcNow, i * 10); + + var (results, indicator) = Sma.Calculate(series, 5); + + // Check results + Assert.Equal(10, results.Count); + Assert.Equal(30.0, results[4].Value); // SMA(10..50) = 30 + Assert.Equal(80.0, results.Last.Value); // SMA(60..100) = 80 + + // Check indicator state + Assert.True(indicator.IsHot); + Assert.Equal(80.0, indicator.Last.Value); + Assert.Equal(5, indicator.WarmupPeriod); + + // Verify indicator continues correctly + indicator.Update(new TValue(DateTime.UtcNow, 110)); + Assert.Equal(90.0, indicator.Last.Value); +} +``` + +### 1.10 Chainability Tests + +#### Required Tests + +| Test Name | Description | Priority | +|-----------|-------------|----------| +| `Chainability_Works` | Verify event-based chaining | 🟡 Required | +| `Pub_EventFires` | Verify `Pub` event fires on update | 🟡 Required | + +#### Implementation Pattern + +```csharp +[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); +} + +[Fact] +public void Pub_EventFires() +{ + var sma = new Sma(10); + bool eventFired = false; + sma.Pub += (object? sender, in TValueEventArgs args) => eventFired = true; + + sma.Update(new TValue(DateTime.UtcNow, 100)); + Assert.True(eventFired); +} +``` + +### 1.11 Indicator-Specific Tests + +Some indicators require additional specialized tests. + +#### Sliding Window Tests (SMA, WMA, etc.) + +```csharp +[Fact] +public void SlidingWindow_Works() +{ + var sma = new Sma(3); + + sma.Update(new TValue(DateTime.UtcNow, 10)); + sma.Update(new TValue(DateTime.UtcNow, 20)); + sma.Update(new TValue(DateTime.UtcNow, 30)); + Assert.Equal(20.0, sma.Last.Value, 1e-10); // (10+20+30)/3 + + sma.Update(new TValue(DateTime.UtcNow, 40)); + Assert.Equal(30.0, sma.Last.Value, 1e-10); // (20+30+40)/3 + + sma.Update(new TValue(DateTime.UtcNow, 50)); + Assert.Equal(40.0, sma.Last.Value, 1e-10); // (30+40+50)/3 +} +``` + +#### Flat Line Tests + +```csharp +[Fact] +public void FlatLine_ReturnsSameValue() +{ + var sma = new Sma(10); + for (int i = 0; i < 20; i++) + { + sma.Update(new TValue(DateTime.UtcNow, 100)); + } + Assert.Equal(100, sma.Last.Value); +} +``` + +#### Multi-Output Indicator Tests (MAMA/FAMA, MACD, etc.) + +```csharp +[Fact] +public void MultiOutput_AllOutputsAccessible() +{ + var mama = new Mama(); + mama.Update(new TValue(DateTime.UtcNow, 100)); + + Assert.True(double.IsFinite(mama.Last.Value)); // MAMA + Assert.True(double.IsFinite(mama.Fama.Value)); // FAMA +} + +[Fact] +public void Calculate_Span_WithSecondaryOutput() +{ + var data = new double[100]; + var mamaOutput = new double[100]; + var famaOutput = new double[100]; + + Mama.Calculate(data, mamaOutput, famaOutput: famaOutput); + + for (int i = 0; i < 100; i++) + { + Assert.True(double.IsFinite(mamaOutput[i])); + Assert.True(double.IsFinite(famaOutput[i])); + } +} +``` + +#### Division-by-Zero Tests (for indicators with denominators) + +```csharp +[Fact] +public void HandlesDivisionByZero() +{ + var adl = new Adl(); + // High = Low = 10. Range = 0. MFM should be 0. + var bar = new TBar(DateTime.UtcNow, 10, 10, 10, 10, 100); + var val = adl.Update(bar); + Assert.Equal(0, val.Value); +} +``` + +### 1.12 Test Data Generation + +Always use the `GBM` (Geometric Brownian Motion) helper for generating realistic test data. + +#### Guidelines + +```csharp +// ✅ CORRECT: Use GBM for random data +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; + +// ❌ WRONG: Do not use System.Random directly +var random = new Random(); // AVOID +double[] data = new double[100]; +for (int i = 0; i < 100; i++) + data[i] = random.NextDouble() * 100; // AVOID +``` + +## 2. Validation Tests (`[Name].Validation.Tests.cs`) + +Validation tests compare the indicator's output against established external libraries to ensure mathematical accuracy. + +### 2.1 Test Class Structure + +```csharp +public sealed class SmaValidationTests : IDisposable +{ + private readonly ValidationTestData _testData; + private readonly ITestOutputHelper _output; + private bool _disposed; + + public SmaValidationTests(ITestOutputHelper output) + { + _output = output; + _testData = new ValidationTestData(); + } + + public void Dispose() + { + Dispose(true); + } + + private void Dispose(bool disposing) + { + if (_disposed) return; + _disposed = true; + if (disposing) _testData?.Dispose(); + } + + // Tests go here... +} +``` + +### 2.2 Required Validation Tests + +For each external library, validate all three API modes: + +| External Library | Tests Required | +|-----------------|----------------| +| **Skender.Stock.Indicators** | `Validate_Skender_Batch`, `Validate_Skender_Streaming`, `Validate_Skender_Span` | +| **TA-Lib** | `Validate_Talib_Batch`, `Validate_Talib_Streaming`, `Validate_Talib_Span` | +| **Tulip** | `Validate_Tulip_Batch`, `Validate_Tulip_Streaming`, `Validate_Tulip_Span` | +| **OoplesFinance** | `Validate_Ooples_Batch` | + +### 2.3 Validation Patterns + +#### Skender Validation + +```csharp +[Fact] +public void Validate_Skender_Batch() +{ + int[] periods = { 5, 10, 20, 50, 100 }; + + foreach (var period in periods) + { + var sma = new Sma(period); + var qResult = sma.Update(_testData.Data); + + var sResult = _testData.SkenderQuotes.GetSma(period).ToList(); + + ValidationHelper.VerifyData(qResult, sResult, (s) => s.Sma); + } + _output.WriteLine("SMA Batch(TSeries) validated against Skender"); +} + +[Fact] +public void Validate_Skender_Streaming() +{ + int[] periods = { 5, 10, 20, 50, 100 }; + + foreach (var period in periods) + { + var sma = new Sma(period); + var qResults = new List(); + foreach (var item in _testData.Data) + { + qResults.Add(sma.Update(item).Value); + } + + var sResult = _testData.SkenderQuotes.GetSma(period).ToList(); + + ValidationHelper.VerifyData(qResults, sResult, (s) => s.Sma); + } + _output.WriteLine("SMA Streaming validated against Skender"); +} + +[Fact] +public void Validate_Skender_Span() +{ + int[] periods = { 5, 10, 20, 50, 100 }; + double[] sourceData = _testData.RawData.ToArray(); + + foreach (var period in periods) + { + double[] qOutput = new double[sourceData.Length]; + Sma.Batch(sourceData.AsSpan(), qOutput.AsSpan(), period); + + var sResult = _testData.SkenderQuotes.GetSma(period).ToList(); + + ValidationHelper.VerifyData(qOutput, sResult, (s) => s.Sma); + } + _output.WriteLine("SMA Span validated against Skender"); +} +``` + +#### TA-Lib Validation + +```csharp +[Fact] +public void Validate_Talib_Batch() +{ + int[] periods = { 5, 10, 20, 50, 100 }; + double[] tData = _testData.RawData.ToArray(); + double[] output = new double[tData.Length]; + + foreach (var period in periods) + { + var sma = new Sma(period); + var qResult = sma.Update(_testData.Data); + + var retCode = TALib.Functions.Sma( + tData, 0..^0, output, out var outRange, period); + Assert.Equal(Core.RetCode.Success, retCode); + + int lookback = TALib.Functions.SmaLookback(period); + + ValidationHelper.VerifyData(qResult, output, outRange, lookback); + } + _output.WriteLine("SMA Batch validated against TA-Lib"); +} +``` + +#### Tulip Validation + +```csharp +[Fact] +public void Validate_Tulip_Batch() +{ + int[] periods = { 5, 10, 20, 50, 100 }; + double[] tData = _testData.RawData.ToArray(); + + foreach (var period in periods) + { + var sma = new Sma(period); + var qResult = sma.Update(_testData.Data); + + var smaIndicator = Tulip.Indicators.sma; + double[][] inputs = { tData }; + double[] options = { period }; + int lookback = period - 1; + double[][] outputs = { new double[tData.Length - lookback] }; + + smaIndicator.Run(inputs, options, outputs); + var tResult = outputs[0]; + + ValidationHelper.VerifyData(qResult, tResult, lookback); + } + _output.WriteLine("SMA Batch validated against Tulip"); +} +``` + +#### OoplesFinance Validation + +```csharp +[Fact] +public void Validate_Ooples_Batch() +{ + int[] periods = { 5, 10, 20, 50, 100 }; + + var ooplesData = _testData.SkenderQuotes.Select(q => new TickerData + { + Date = q.Date, + Close = (double)q.Close, + High = (double)q.High, + Low = (double)q.Low, + Open = (double)q.Open, + Volume = (double)q.Volume + }).ToList(); + + foreach (var period in periods) + { + var sma = new Sma(period); + var qResult = sma.Update(_testData.Data); + + var stockData = new StockData(ooplesData); + var sResult = Calculations.CalculateSimpleMovingAverage(stockData, period) + .OutputValues.Values.First(); + + ValidationHelper.VerifyData(qResult, sResult, + (s) => s, 100, ValidationHelper.OoplesTolerance); + } + _output.WriteLine("SMA Batch validated against Ooples"); +} +``` + +### 2.4 Tolerance Constants + +Use explicit tolerance constants from `ValidationHelper`: + +```csharp +// Standard tolerances +ValidationHelper.SkenderTolerance // 1e-9 +ValidationHelper.TalibTolerance // 1e-9 +ValidationHelper.TulipTolerance // 1e-9 +ValidationHelper.OoplesTolerance // 1e-6 +``` + +## 3. Quantower Adapter Tests (`[Name].Quantower.Tests.cs`) + +These tests verify the Quantower platform integration. + +### 3.1 Required Tests + +| Test Name | Description | Priority | +|-----------|-------------|----------| +| `Constructor_SetsDefaults` | Verify default property values | 🔴 Critical | +| `MinHistoryDepths_IsCorrect` | Verify history requirements | 🟡 Required | +| `ShortName_IncludesParameters` | Verify display name | 🟡 Required | +| `Initialize_CreatesInternalIndicator` | Verify initialization | 🔴 Critical | +| `ProcessUpdate_HistoricalBar_ComputesValue` | Verify historical processing | 🔴 Critical | +| `ProcessUpdate_NewBar_ComputesValue` | Verify new bar processing | 🔴 Critical | +| `ProcessUpdate_NewTick_ProcessesWithoutError` | Verify tick processing | 🟡 Required | +| `MultipleUpdates_ProducesCorrectSequence` | Verify sequence processing | 🟡 Required | +| `DifferentSourceTypes_Work` | Verify OHLC source types | 🟡 Required | +| `Length_CanBeChanged` | Verify parameter modification | 🟢 Recommended | + +### 3.2 Implementation Pattern + +```csharp +public class SmaIndicatorTests +{ + [Fact] + public void SmaIndicator_Constructor_SetsDefaults() + { + var indicator = new SmaIndicator(); + + Assert.Equal(14, indicator.Period); + Assert.Equal(SourceType.Close, indicator.Source); + Assert.True(indicator.ShowColdValues); + Assert.Equal("SMA - Simple Moving Average", indicator.Name); + Assert.False(indicator.SeparateWindow); + Assert.True(indicator.OnBackGround); + } + + [Fact] + public void SmaIndicator_Initialize_CreatesInternalFilter() + { + var indicator = new SmaIndicator { Period = 14 }; + indicator.Initialize(); + Assert.Single(indicator.LinesSeries); + } + + [Fact] + public void SmaIndicator_ProcessUpdate_HistoricalBar_ComputesValue() + { + var indicator = new SmaIndicator { Period = 3 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 105, 95, 102); + + var args = new UpdateArgs(UpdateReason.HistoricalBar); + indicator.ProcessUpdate(args); + + Assert.Equal(1, indicator.LinesSeries[0].Count); + Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0))); + } + + [Fact] + public void SmaIndicator_ProcessUpdate_NewBar_ComputesValue() + { + var indicator = new SmaIndicator { Period = 3 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 105, 95, 102); + indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar)); + + Assert.Equal(2, indicator.LinesSeries[0].Count); + } + + [Fact] + public void SmaIndicator_DifferentSourceTypes_Work() + { + var sources = new[] + { + SourceType.Open, + SourceType.High, + SourceType.Low, + SourceType.Close, + SourceType.HL2, + SourceType.HLC3, + }; + + foreach (var source in sources) + { + var indicator = new SmaIndicator { Period = 3, Source = source }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 110, 90, 105); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + + Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)), + $"Source {source} should produce finite value"); + } + } +} +``` + +## 4. Volume/TBar Indicator Tests + +For indicators that require OHLCV data (TBar input). + +### 4.1 Additional Required Tests + +| Test Name | Description | Priority | +|-----------|-------------|----------| +| `BasicCalculation_ReturnsExpectedValues` | Verify with known inputs | 🔴 Critical | +| `UpdateTBarSeries_ReturnsCorrectSeries` | Verify series processing | 🔴 Critical | +| `CalculateTBarSeries_ReturnsCorrectSeries` | Verify static method | 🟡 Required | +| `CalculateSpan_ReturnsCorrectValues` | Verify span with all inputs | 🟡 Required | +| `CalculateSpan_ThrowsOnMismatchedLengths` | Verify length validation | 🟡 Required | +| `TValueUpdate_DoesNotChangeValue` | Verify TValue ignored | 🟡 Required | + +### 4.2 Implementation Pattern + +```csharp +[Fact] +public void Adl_BasicCalculation_ReturnsExpectedValues() +{ + var adl = new Adl(); + var time = DateTime.UtcNow; + + // Bar 1: Close=10, High=12, Low=8. Range=4. + // MFM = ((10-8) - (12-10)) / 4 = 0 + var bar1 = new TBar(time, 10, 12, 8, 10, 100); + var val1 = adl.Update(bar1); + Assert.Equal(0, val1.Value); + + // Bar 2: Close=12 (at high). MFM = 1. + var bar2 = new TBar(time.AddMinutes(1), 10, 12, 8, 12, 200); + var val2 = adl.Update(bar2); + Assert.Equal(200, val2.Value); +} + +[Fact] +public void Adl_CalculateSpan_ReturnsCorrectValues() +{ + double[] high = { 12, 12, 12 }; + double[] low = { 8, 8, 8 }; + double[] close = { 10, 12, 8 }; + double[] volume = { 100, 200, 100 }; + double[] output = new double[3]; + + Adl.Calculate(high, low, close, volume, output); + + Assert.Equal(0, output[0]); + Assert.Equal(200, output[1]); + Assert.Equal(100, output[2]); +} + +[Fact] +public void Adl_CalculateSpan_ThrowsOnMismatchedLengths() +{ + double[] high = { 10, 11 }; + double[] low = { 9, 10 }; + double[] close = { 9.5, 10.5 }; + double[] volume = { 100 }; // Mismatched + double[] output = new double[2]; + + Assert.Throws(() => + Adl.Calculate(high, low, close, volume, output)); +} +``` + +## 5. Test Checklist Summary + +### Mandatory Tests (Every Indicator) + +- [ ] `Constructor_ValidatesInput` +- [ ] `Calc_ReturnsValue` +- [ ] `Calc_IsNew_AcceptsParameter` +- [ ] `Calc_IsNew_False_UpdatesValue` +- [ ] `IterativeCorrections_RestoreToOriginalState` +- [ ] `Reset_ClearsState` +- [ ] `IsHot_BecomesTrueWhenBufferFull` +- [ ] `NaN_Input_UsesLastValidValue` +- [ ] `Infinity_Input_UsesLastValidValue` +- [ ] `BatchCalc_MatchesIterativeCalc` +- [ ] `AllModes_ProduceSameResult` +- [ ] `SpanBatch_ValidatesInput` +- [ ] `SpanBatch_MatchesTSeriesBatch` + +### Validation Tests (At Least One) + +- [ ] `Validate_Skender_Batch` +- [ ] `Validate_Skender_Streaming` +- [ ] `Validate_Skender_Span` +- [ ] `Validate_Talib_Batch` (if available) +- [ ] `Validate_Tulip_Batch` (if available) + +### Quantower Tests + +- [ ] `Constructor_SetsDefaults` +- [ ] `Initialize_CreatesInternalIndicator` +- [ ] `ProcessUpdate_HistoricalBar_ComputesValue` +- [ ] `ProcessUpdate_NewBar_ComputesValue` +- [ ] `DifferentSourceTypes_Work` + +## 6. Test Naming Conventions + +Follow this pattern for test method names: + +``` +[MethodUnderTest]_[Scenario]_[ExpectedBehavior] +``` + +Examples: +- `Constructor_InvalidPeriod_ThrowsArgumentException` +- `Update_NaNInput_UsesLastValidValue` +- `SpanBatch_MismatchedLengths_ThrowsArgumentException` +- `AllModes_SameInput_ProduceSameResult` + +## 7. Assertions Best Practices + +### Numeric Comparisons + +```csharp +// For exact matches +Assert.Equal(expected, actual, 1e-10); + +// For approximate matches (floating point) +Assert.Equal(expected, actual, precision: 9); + +// For range checks +Assert.InRange(value, min, max); + +// For finite checks +Assert.True(double.IsFinite(value)); +``` + +### Exception Assertions + +```csharp +// Verify exception type +Assert.Throws(() => new Sma(0)); + +// Verify exception parameter name (MA0015 compliance) +var ex = Assert.Throws(() => + Sma.Batch(source, output, 0)); +Assert.Equal("period", ex.ParamName); +``` + +### Collection Assertions + +```csharp +// Verify count +Assert.Equal(expected.Count, actual.Count); + +// Verify empty +Assert.Empty(result); + +// Verify single +Assert.Single(indicator.LinesSeries); +``` diff --git a/docs/momentum-test-implementation-plan.md b/docs/momentum-test-implementation-plan.md new file mode 100644 index 00000000..2fdfc7c5 --- /dev/null +++ b/docs/momentum-test-implementation-plan.md @@ -0,0 +1,902 @@ +# Momentum Indicators Test Implementation Plan + +> **Objective:** Bring all 13 momentum indicators to full compliance with testprotocol.md + +## Executive Summary + +- **Total Missing Tests:** ~72 tests across 12 indicators +- **Estimated Effort:** 4-6 hours +- **Priority:** Start with MACD (most deficient), end with VEL (closest to compliant) + +--- + +## Phase 1: Critical Deficiencies (MACD, BOP) + +### 1.1 MACD - Add 10 Tests + +**File:** `lib/momentum/macd/Macd.Tests.cs` + +```csharp +// ADD THESE TESTS: + +[Fact] +public void Constructor_InvalidParameters_ThrowsArgumentException() +{ + Assert.Throws(() => new Macd(0, 26, 9)); + Assert.Throws(() => new Macd(12, 0, 9)); + Assert.Throws(() => new Macd(12, 26, 0)); + Assert.Throws(() => new Macd(26, 12, 9)); // fast >= slow +} + +[Fact] +public void Calc_IsNew_AcceptsParameter() +{ + var macd = new Macd(12, 26, 9); + var gbm = new GBM(); + var series = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 49; i++) + macd.Update(series.Close[i], isNew: true); + + var val1 = macd.Update(series.Close[49], isNew: true); + var val2 = macd.Update(new TValue(DateTime.UtcNow, series.Close[49].Value + 1), isNew: true); + + Assert.NotEqual(val1.Value, val2.Value); +} + +[Fact] +public void Calc_IsNew_False_UpdatesValue() +{ + var macd = new Macd(12, 26, 9); + var gbm = new GBM(); + var series = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 49; i++) + macd.Update(series.Close[i]); + + var val1 = macd.Update(series.Close[49], isNew: true); + var val2 = macd.Update(new TValue(series.Close[49].Time, series.Close[49].Value + 5), isNew: false); + + Assert.Equal(val1.Time, val2.Time); + Assert.NotEqual(val1.Value, val2.Value); +} + +[Fact] +public void IterativeCorrections_RestoreToOriginalState() +{ + var macd = new Macd(12, 26, 9); + var gbm = new GBM(); + var series = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 50; i++) + macd.Update(series.Close[i]); + + var originalValue = macd.Last; + + for (int m = 0; m < 5; m++) + { + var modified = new TValue(series.Close[49].Time, series.Close[49].Value + m); + macd.Update(modified, isNew: false); + } + + var restored = macd.Update(series.Close[49], isNew: false); + Assert.Equal(originalValue.Value, restored.Value, 1e-9); +} + +[Fact] +public void Reset_ClearsState() +{ + var macd = new Macd(12, 26, 9); + var gbm = new GBM(); + var series = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < series.Count; i++) + macd.Update(series.Close[i]); + + macd.Reset(); + + Assert.Equal(0, macd.Last.Value); + Assert.False(macd.IsHot); +} + +[Fact] +public void IsHot_BecomesTrueWhenBufferFull() +{ + var macd = new Macd(12, 26, 9); + var gbm = new GBM(); + var series = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + Assert.False(macd.IsHot); + + for (int i = 0; i < series.Count; i++) + { + macd.Update(series.Close[i]); + if (i >= 40) break; // Should be hot by warmup + } + + Assert.True(macd.IsHot); +} + +[Fact] +public void NaN_Input_UsesLastValidValue() +{ + var macd = new Macd(12, 26, 9); + var gbm = new GBM(); + var series = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 40; i++) + macd.Update(series.Close[i]); + + var result = macd.Update(new TValue(DateTime.UtcNow, double.NaN)); + Assert.True(double.IsFinite(result.Value)); +} + +[Fact] +public void Infinity_Input_UsesLastValidValue() +{ + var macd = new Macd(12, 26, 9); + var gbm = new GBM(); + var series = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 40; i++) + macd.Update(series.Close[i]); + + var result = macd.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity)); + Assert.True(double.IsFinite(result.Value)); +} + +[Fact] +public void AllModes_ProduceSameResult() +{ + var gbm = new GBM(seed: 123); + var series = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // 1. Batch Mode + var batchMacd = new Macd(12, 26, 9); + var batchResult = batchMacd.Update(series.Close); + double expected = batchResult.Last.Value; + + // 2. Span Mode + var spanOutput = new double[series.Count]; + Macd.Calculate(series.Close.Values, spanOutput, 12, 26); + double spanResult = spanOutput[^1]; + + // 3. Streaming Mode + var streamMacd = new Macd(12, 26, 9); + for (int i = 0; i < series.Count; i++) + streamMacd.Update(series.Close[i]); + double streamResult = streamMacd.Last.Value; + + // 4. Eventing Mode + var pubSource = new TSeries(); + var eventMacd = new Macd(pubSource, 12, 26, 9); + for (int i = 0; i < series.Count; i++) + pubSource.Add(series.Close[i]); + double eventResult = eventMacd.Last.Value; + + Assert.Equal(expected, spanResult, 9); + Assert.Equal(expected, streamResult, 9); + Assert.Equal(expected, eventResult, 9); +} + +[Fact] +public void SpanBatch_ValidatesInput() +{ + double[] source = [1, 2, 3, 4, 5]; + double[] output = new double[5]; + double[] wrongSize = new double[3]; + + Assert.Throws(() => Macd.Calculate(source, wrongSize, 12, 26)); + Assert.Throws(() => Macd.Calculate(source, output, 0, 26)); + Assert.Throws(() => Macd.Calculate(source, output, 12, 0)); +} +``` + +### 1.2 BOP - Add 9 Tests + +**File:** `lib/momentum/bop/Bop.Tests.cs` + +```csharp +// ADD THESE TESTS: + +[Fact] +public void Calc_IsNew_AcceptsParameter() +{ + var bop = new Bop(); + var bar1 = new TBar(DateTime.UtcNow, 10, 20, 5, 15, 100); + var bar2 = new TBar(DateTime.UtcNow, 15, 25, 10, 20, 100); + + bop.Update(bar1, isNew: true); + var val1 = bop.Last.Value; + + bop.Update(bar2, isNew: true); + var val2 = bop.Last.Value; + + Assert.NotEqual(val1, val2); +} + +[Fact] +public void Calc_IsNew_False_UpdatesValue() +{ + var bop = new Bop(); + var bar1 = new TBar(DateTime.UtcNow, 10, 20, 5, 15, 100); + var bar2 = new TBar(DateTime.UtcNow, 10, 25, 5, 20, 100); + + var val1 = bop.Update(bar1, isNew: true); + var val2 = bop.Update(bar2, isNew: false); + + Assert.Equal(val1.Time, val2.Time); + Assert.NotEqual(val1.Value, val2.Value); +} + +[Fact] +public void IterativeCorrections_RestoreToOriginalState() +{ + var bop = new Bop(); + var bar = new TBar(DateTime.UtcNow, 10, 20, 5, 15, 100); + + var originalValue = bop.Update(bar, isNew: true); + + for (int i = 0; i < 5; i++) + { + var modified = new TBar(bar.Time, bar.Open, bar.High + i, bar.Low, bar.Close, bar.Volume); + bop.Update(modified, isNew: false); + } + + var restored = bop.Update(bar, isNew: false); + Assert.Equal(originalValue.Value, restored.Value, 1e-9); +} + +[Fact] +public void Reset_ClearsState() +{ + var bop = new Bop(); + var bar = new TBar(DateTime.UtcNow, 10, 20, 5, 15, 100); + + bop.Update(bar); + bop.Reset(); + + Assert.Equal(0, bop.Last.Value); +} + +[Fact] +public void IsHot_BecomesTrueWhenBufferFull() +{ + var bop = new Bop(); + + Assert.False(bop.IsHot); + + var bar = new TBar(DateTime.UtcNow, 10, 20, 5, 15, 100); + bop.Update(bar); + + Assert.True(bop.IsHot); // BOP is hot immediately (no warmup needed) +} + +[Fact] +public void NaN_Input_UsesLastValidValue() +{ + var bop = new Bop(); + var bar1 = new TBar(DateTime.UtcNow, 10, 20, 5, 15, 100); + var barNaN = new TBar(DateTime.UtcNow, double.NaN, 20, 5, 15, 100); + + bop.Update(bar1); + var result = bop.Update(barNaN); + + Assert.True(double.IsFinite(result.Value)); +} + +[Fact] +public void Infinity_Input_UsesLastValidValue() +{ + var bop = new Bop(); + var bar1 = new TBar(DateTime.UtcNow, 10, 20, 5, 15, 100); + var barInf = new TBar(DateTime.UtcNow, double.PositiveInfinity, 20, 5, 15, 100); + + bop.Update(bar1); + var result = bop.Update(barInf); + + Assert.True(double.IsFinite(result.Value)); +} + +[Fact] +public void AllModes_ProduceSameResult() +{ + var gbm = new GBM(seed: 123); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // 1. Batch Mode + var batchResult = Bop.Batch(bars); + double expected = batchResult.Last.Value; + + // 2. Span Mode + var spanOutput = new double[bars.Count]; + Bop.Calculate(bars.Open.Values, bars.High.Values, bars.Low.Values, bars.Close.Values, spanOutput); + double spanResult = spanOutput[^1]; + + // 3. Streaming Mode + var streamBop = new Bop(); + for (int i = 0; i < bars.Count; i++) + streamBop.Update(bars[i]); + double streamResult = streamBop.Last.Value; + + Assert.Equal(expected, spanResult, 9); + Assert.Equal(expected, streamResult, 9); +} + +[Fact] +public void SpanBatch_ValidatesInput() +{ + double[] open = [1, 2, 3]; + double[] high = [2, 3, 4]; + double[] low = [0, 1, 2]; + double[] close = [1.5, 2.5, 3.5]; + double[] output = new double[3]; + double[] wrongSize = new double[2]; + + Assert.Throws(() => Bop.Calculate(open, high, low, close, wrongSize)); +} +``` + +--- + +## Phase 2: Medium Deficiencies (DMX, CFB) + +### 2.1 DMX - Add 7 Tests + +**File:** `lib/momentum/dmx/Dmx.Tests.cs` + +```csharp +// ADD THESE TESTS: + +[Fact] +public void Constructor_InvalidParameters_ThrowsArgumentException() +{ + Assert.Throws(() => new Dmx(0)); + Assert.Throws(() => new Dmx(-1)); +} + +[Fact] +public void IterativeCorrections_RestoreToOriginalState() +{ + var dmx = new Dmx(14); + var gbm = new GBM(); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 50; i++) + dmx.Update(bars[i]); + + var originalValue = dmx.Last; + + for (int m = 0; m < 5; m++) + { + var modified = new TBar(bars[49].Time, bars[49].Open, bars[49].High + m, bars[49].Low - m, bars[49].Close, bars[49].Volume); + dmx.Update(modified, isNew: false); + } + + var restored = dmx.Update(bars[49], isNew: false); + Assert.Equal(originalValue.Value, restored.Value, 1e-9); +} + +[Fact] +public void IsHot_BecomesTrueWhenBufferFull() +{ + var dmx = new Dmx(14); + var gbm = new GBM(); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + Assert.False(dmx.IsHot); + + for (int i = 0; i < bars.Count; i++) + { + dmx.Update(bars[i]); + if (dmx.IsHot) break; + } + + Assert.True(dmx.IsHot); +} + +[Fact] +public void NaN_Input_UsesLastValidValue() +{ + var dmx = new Dmx(14); + var gbm = new GBM(); + var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 30; i++) + dmx.Update(bars[i]); + + var nanBar = new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, 100); + var result = dmx.Update(nanBar); + + Assert.True(double.IsFinite(result.Value)); +} + +[Fact] +public void Infinity_Input_UsesLastValidValue() +{ + var dmx = new Dmx(14); + var gbm = new GBM(); + var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 30; i++) + dmx.Update(bars[i]); + + var infBar = new TBar(DateTime.UtcNow, double.PositiveInfinity, double.PositiveInfinity, 0, 100, 100); + var result = dmx.Update(infBar); + + Assert.True(double.IsFinite(result.Value)); +} + +[Fact] +public void AllModes_ProduceSameResult() +{ + var gbm = new GBM(seed: 123); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // 1. Batch Mode + var batchResult = Dmx.Batch(bars, 14); + double expected = batchResult.Last.Value; + + // 2. Streaming Mode + var streamDmx = new Dmx(14); + for (int i = 0; i < bars.Count; i++) + streamDmx.Update(bars[i]); + double streamResult = streamDmx.Last.Value; + + Assert.Equal(expected, streamResult, 9); +} + +[Fact] +public void SpanBatch_ValidatesInput() +{ + // Add if DMX has span API +} +``` + +### 2.2 CFB - Add 5 Tests + +**File:** `lib/momentum/cfb/Cfb.Tests.cs` + +```csharp +// ADD THESE TESTS: + +[Fact] +public void Constructor_InvalidParameters_ThrowsArgumentException() +{ + Assert.Throws(() => new Cfb(Array.Empty())); + Assert.Throws(() => new Cfb(new[] { 0, 10 })); + Assert.Throws(() => new Cfb(new[] { -1, 10 })); +} + +[Fact] +public void IterativeCorrections_RestoreToOriginalState() +{ + var cfb = new Cfb(); + var gbm = new GBM(); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 50; i++) + cfb.Update(new TValue(bars.Close.Times[i], bars.Close.Values[i])); + + var originalValue = cfb.Last; + + for (int m = 0; m < 5; m++) + { + var modified = new TValue(bars.Close.Times[49], bars.Close.Values[49] + m); + cfb.Update(modified, isNew: false); + } + + var restored = cfb.Update(new TValue(bars.Close.Times[49], bars.Close.Values[49]), isNew: false); + Assert.Equal(originalValue.Value, restored.Value, 1e-9); +} + +[Fact] +public void IsHot_BecomesTrueWhenBufferFull() +{ + var cfb = new Cfb(new[] { 5, 10 }); + var gbm = new GBM(); + var bars = gbm.Fetch(50, 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])); + if (cfb.IsHot) break; + } + + Assert.True(cfb.IsHot); +} + +[Fact] +public void NaN_Input_UsesLastValidValue() +{ + var cfb = new Cfb(); + var gbm = new GBM(); + var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 30; i++) + cfb.Update(new TValue(bars.Close.Times[i], bars.Close.Values[i])); + + var result = cfb.Update(new TValue(DateTime.UtcNow, double.NaN)); + Assert.True(double.IsFinite(result.Value)); +} + +[Fact] +public void Infinity_Input_UsesLastValidValue() +{ + var cfb = new Cfb(); + var gbm = new GBM(); + var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 30; i++) + cfb.Update(new TValue(bars.Close.Times[i], bars.Close.Values[i])); + + var result = cfb.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity)); + Assert.True(double.IsFinite(result.Value)); +} + +[Fact] +public void AllModes_ProduceSameResult() +{ + var gbm = new GBM(seed: 123); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // 1. Batch Mode + var batchResult = Cfb.Batch(bars.Close); + double expected = batchResult.Last.Value; + + // 2. Span Mode + var spanOutput = new double[bars.Count]; + Cfb.Batch(bars.Close.Values.ToArray(), spanOutput); + double spanResult = spanOutput[^1]; + + // 3. Streaming Mode + var streamCfb = new Cfb(); + for (int i = 0; i < bars.Count; i++) + streamCfb.Update(new TValue(bars.Close.Times[i], bars.Close.Values[i])); + double streamResult = streamCfb.Last.Value; + + Assert.Equal(expected, spanResult, 9); + Assert.Equal(expected, streamResult, 9); +} +``` + +--- + +## Phase 3: Standard Deficiencies (ADX, ADXR, AO, APO, Aroon, AroonOsc) + +These 6 indicators all have the same pattern of missing tests. Create a template: + +### Template for TBar-based Indicators (ADX, ADXR, AO, Aroon, AroonOsc) + +```csharp +// ADD THESE 6 TESTS TO EACH: + +[Fact] +public void IterativeCorrections_RestoreToOriginalState() +{ + var indicator = new [IndicatorName](period); + var gbm = new GBM(); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 50; i++) + indicator.Update(bars[i]); + + var originalValue = indicator.Last; + + for (int m = 0; m < 5; m++) + { + var modified = new TBar(bars[49].Time, bars[49].Open, bars[49].High + m, bars[49].Low - m, bars[49].Close, bars[49].Volume); + indicator.Update(modified, isNew: false); + } + + var restored = indicator.Update(bars[49], isNew: false); + Assert.Equal(originalValue.Value, restored.Value, 1e-9); +} + +[Fact] +public void IsHot_BecomesTrueWhenBufferFull() +{ + var indicator = new [IndicatorName](period); + var gbm = new GBM(); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + Assert.False(indicator.IsHot); + + for (int i = 0; i < bars.Count; i++) + { + indicator.Update(bars[i]); + if (indicator.IsHot) break; + } + + Assert.True(indicator.IsHot); +} + +[Fact] +public void NaN_Input_UsesLastValidValue() +{ + var indicator = new [IndicatorName](period); + var gbm = new GBM(); + var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 40; i++) + indicator.Update(bars[i]); + + var nanBar = new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, 100); + var result = indicator.Update(nanBar); + + Assert.True(double.IsFinite(result.Value)); +} + +[Fact] +public void Infinity_Input_UsesLastValidValue() +{ + var indicator = new [IndicatorName](period); + var gbm = new GBM(); + var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 40; i++) + indicator.Update(bars[i]); + + var infBar = new TBar(DateTime.UtcNow, double.PositiveInfinity, double.PositiveInfinity, 0, 100, 100); + var result = indicator.Update(infBar); + + Assert.True(double.IsFinite(result.Value)); +} + +[Fact] +public void AllModes_ProduceSameResult() +{ + var gbm = new GBM(seed: 123); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // 1. Batch Mode + var batchResult = [IndicatorName].Batch(bars, period); + double expected = batchResult.Last.Value; + + // 2. Streaming Mode + var streamIndicator = new [IndicatorName](period); + for (int i = 0; i < bars.Count; i++) + streamIndicator.Update(bars[i]); + double streamResult = streamIndicator.Last.Value; + + Assert.Equal(expected, streamResult, 9); +} + +[Fact] +public void SpanBatch_ValidatesInput() +{ + // Implement if indicator has Span API +} +``` + +### Template for TValue-based Indicator (APO) + +Similar pattern but uses `series.Close[i]` instead of `bars[i]`. + +--- + +## Phase 4: Minor Deficiencies (RSX, VEL) + +### 4.1 RSX - Add 4 Tests + +```csharp +[Fact] +public void IterativeCorrections_RestoreToOriginalState() +{ + var rsx = new Rsx(14); + var gbm = new GBM(); + var series = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 50; i++) + rsx.Update(new TValue(series.Close.Times[i], series.Close.Values[i])); + + var originalValue = rsx.Last; + + for (int m = 0; m < 5; m++) + { + var modified = new TValue(series.Close.Times[49], series.Close.Values[49] + m); + rsx.Update(modified, isNew: false); + } + + var restored = rsx.Update(new TValue(series.Close.Times[49], series.Close.Values[49]), isNew: false); + Assert.Equal(originalValue.Value, restored.Value, 1e-9); +} + +[Fact] +public void IsHot_BecomesTrueWhenBufferFull() +{ + var rsx = new Rsx(14); + var gbm = new GBM(); + var series = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + Assert.False(rsx.IsHot); + + for (int i = 0; i < series.Count; i++) + { + rsx.Update(new TValue(series.Close.Times[i], series.Close.Values[i])); + if (rsx.IsHot) break; + } + + Assert.True(rsx.IsHot); +} + +[Fact] +public void Infinity_Input_UsesLastValidValue() +{ + var rsx = new Rsx(14); + rsx.Update(new TValue(DateTime.UtcNow, 100)); + var result = rsx.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity)); + + Assert.False(double.IsInfinity(result.Value)); + Assert.InRange(result.Value, 0, 100); +} + +[Fact] +public void AllModes_ProduceSameResult() +{ + int period = 14; + var gbm = new GBM(seed: 123); + var series = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // 1. Batch Mode + var batchResult = Rsx.Batch(series.Close, period); + double expected = batchResult.Last.Value; + + // 2. Span Mode + var spanOutput = new double[series.Count]; + Rsx.Batch(series.Close.Values.ToArray(), spanOutput, period); + double spanResult = spanOutput[^1]; + + // 3. Streaming Mode + var streamRsx = new Rsx(period); + for (int i = 0; i < series.Count; i++) + streamRsx.Update(new TValue(series.Close.Times[i], series.Close.Values[i])); + double streamResult = streamRsx.Last.Value; + + // 4. Eventing Mode + var pubSource = new TSeries(); + var eventRsx = new Rsx(pubSource, period); + for (int i = 0; i < series.Count; i++) + pubSource.Add(new TValue(series.Close.Times[i], series.Close.Values[i])); + double eventResult = eventRsx.Last.Value; + + Assert.Equal(expected, spanResult, 9); + Assert.Equal(expected, streamResult, 9); + Assert.Equal(expected, eventResult, 9); +} +``` + +### 4.2 VEL - Add 4 Tests + +```csharp +[Fact] +public void IterativeCorrections_RestoreToOriginalState() +{ + var vel = new Vel(10); + var gbm = new GBM(); + var series = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 50; i++) + vel.Update(series.Close[i]); + + var originalValue = vel.Last; + + for (int m = 0; m < 5; m++) + { + var modified = new TValue(series.Close[49].Time, series.Close[49].Value + m); + vel.Update(modified, isNew: false); + } + + var restored = vel.Update(series.Close[49], isNew: false); + Assert.Equal(originalValue.Value, restored.Value, 1e-9); +} + +[Fact] +public void NaN_Input_UsesLastValidValue() +{ + var vel = new Vel(10); + var gbm = new GBM(); + var series = gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 15; i++) + vel.Update(series.Close[i]); + + var result = vel.Update(new TValue(DateTime.UtcNow, double.NaN)); + Assert.True(double.IsFinite(result.Value)); +} + +[Fact] +public void Infinity_Input_UsesLastValidValue() +{ + var vel = new Vel(10); + var gbm = new GBM(); + var series = gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 15; i++) + vel.Update(series.Close[i]); + + var result = vel.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity)); + Assert.True(double.IsFinite(result.Value)); +} + +[Fact] +public void AllModes_ProduceSameResult() +{ + int period = 10; + var gbm = new GBM(seed: 123); + var series = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // 1. Batch Mode + var batchResult = Vel.Batch(series.Close, period); + double expected = batchResult.Last.Value; + + // 2. Span Mode + var spanOutput = new double[series.Count]; + Vel.Batch(series.Close.Values.ToArray().AsSpan(), spanOutput.AsSpan(), period); + double spanResult = spanOutput[^1]; + + // 3. Streaming Mode + var streamVel = new Vel(period); + for (int i = 0; i < series.Count; i++) + streamVel.Update(series.Close[i]); + double streamResult = streamVel.Last.Value; + + // 4. Eventing Mode + var pubSource = new TSeries(); + var eventVel = new Vel(pubSource, period); + for (int i = 0; i < series.Count; i++) + pubSource.Add(series.Close[i]); + double eventResult = eventVel.Last.Value; + + Assert.Equal(expected, spanResult, 9); + Assert.Equal(expected, streamResult, 9); + Assert.Equal(expected, eventResult, 9); +} + +[Fact] +public void SpanBatch_ValidatesInput() +{ + double[] source = [1, 2, 3, 4, 5]; + double[] output = new double[5]; + double[] wrongSize = new double[3]; + + Assert.Throws(() => Vel.Batch(source.AsSpan(), wrongSize.AsSpan(), 3)); + Assert.Throws(() => Vel.Batch(source.AsSpan(), output.AsSpan(), 0)); + Assert.Throws(() => Vel.Batch(source.AsSpan(), output.AsSpan(), -1)); +} +``` + +--- + +## Implementation Checklist + +### Phase 1 (Priority: Critical) +- [ ] MACD.Tests.cs - Add 10 tests +- [ ] BOP.Tests.cs - Add 9 tests + +### Phase 2 (Priority: High) +- [ ] DMX.Tests.cs - Add 7 tests +- [ ] CFB.Tests.cs - Add 5 tests + +### Phase 3 (Priority: Medium) +- [ ] ADX.Tests.cs - Add 6 tests +- [ ] ADXR.Tests.cs - Add 6 tests +- [ ] AO.Tests.cs - Add 6 tests +- [ ] APO.Tests.cs - Add 6 tests +- [ ] Aroon.Tests.cs - Add 6 tests +- [ ] AroonOsc.Tests.cs - Add 6 tests + +### Phase 4 (Priority: Low) +- [ ] RSX.Tests.cs - Add 4 tests +- [ ] VEL.Tests.cs - Add 4 tests + +--- + +## Verification Steps + +After implementing all tests: + +1. Run all tests: `dotnet test lib/QuanTAlib.Tests.csproj` +2. Verify no regressions in existing tests +3. Check test coverage meets targets +4. Update docs/validation.md with compliance status diff --git a/lib/core/AbstractBase.cs b/lib/core/AbstractBase.cs index d7633f5d..aeb948c7 100644 --- a/lib/core/AbstractBase.cs +++ b/lib/core/AbstractBase.cs @@ -1,4 +1,5 @@ using System; +using System.Runtime.CompilerServices; namespace QuanTAlib; @@ -36,6 +37,7 @@ public abstract class AbstractBase : ITValuePublisher /// /// Helper to invoke the Pub event. /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] protected void PubEvent(TValue value, bool isNew = true) { Pub?.Invoke(this, new TValueEventArgs { Value = value, IsNew = isNew }); diff --git a/lib/core/ringbuffer/RingBuffer.Tests.cs b/lib/core/ringbuffer/RingBuffer.Tests.cs index 1a74cbd1..9381a8d3 100644 --- a/lib/core/ringbuffer/RingBuffer.Tests.cs +++ b/lib/core/ringbuffer/RingBuffer.Tests.cs @@ -719,4 +719,175 @@ public class RingBufferTests Assert.Equal(21.666666666666668, buffer.Average, 1e-10); Assert.NotEqual(avgBeforeCorrection, buffer.Average); } + + [Fact] + public void Snapshot_CapturesCurrentState() + { + var buffer = new RingBuffer(5); + + buffer.Add(10.0); + buffer.Add(20.0); + buffer.Add(30.0); + + buffer.Snapshot(); + + // Modify buffer after snapshot + buffer.Add(40.0); + + Assert.Equal(4, buffer.Count); + Assert.Equal(100.0, buffer.Sum); // 10 + 20 + 30 + 40 + } + + [Fact] + public void Restore_ReturnsToSnapshotState() + { + var buffer = new RingBuffer(5); + + buffer.Add(10.0); + buffer.Add(20.0); + buffer.Add(30.0); + + buffer.Snapshot(); + double sumBeforeModification = buffer.Sum; + int countBeforeModification = buffer.Count; + + // Modify buffer after snapshot + buffer.Add(40.0); + Assert.Equal(4, buffer.Count); + + // Restore to snapshot state + buffer.Restore(); + + Assert.Equal(countBeforeModification, buffer.Count); + Assert.Equal(sumBeforeModification, buffer.Sum); + } + + [Fact] + public void Snapshot_Restore_WithWrapping() + { + var buffer = new RingBuffer(3); + + buffer.Add(10.0); + buffer.Add(20.0); + buffer.Add(30.0); + + buffer.Snapshot(); + + // Add value that causes wrap + buffer.Add(40.0); + Assert.Equal(90.0, buffer.Sum); // 20 + 30 + 40 + + buffer.Restore(); + + Assert.Equal(60.0, buffer.Sum); // 10 + 20 + 30 + Assert.Equal(30.0, buffer.Newest); + } + + [Fact] + public void RecalculateSum_CorrectsDrift() + { + var buffer = new RingBuffer(5); + + buffer.Add(10.0); + buffer.Add(20.0); + buffer.Add(30.0); + + double recalculated = buffer.RecalculateSum(); + + Assert.Equal(60.0, recalculated); + Assert.Equal(60.0, buffer.Sum); + } + + [Fact] + public void RecalculateSum_AfterMultipleOperations() + { + var buffer = new RingBuffer(3); + + // Simulate many operations that could accumulate floating-point drift + for (int i = 0; i < 100; i++) + { + buffer.Add(i * 0.1); + } + + double recalculated = buffer.RecalculateSum(); + + // Should be equal (or very close) since we're using exact values + Assert.Equal(recalculated, buffer.Sum); + } + + [Fact] + public void StartIndex_EmptyBuffer_ReturnsZero() + { + var buffer = new RingBuffer(5); + + Assert.Equal(0, buffer.StartIndex); + } + + [Fact] + public void StartIndex_PartiallyFilled_ReturnsZero() + { + var buffer = new RingBuffer(5); + + buffer.Add(10.0); + buffer.Add(20.0); + + Assert.Equal(0, buffer.StartIndex); + } + + [Fact] + public void StartIndex_FullBuffer_ReturnsHead() + { + var buffer = new RingBuffer(3); + + buffer.Add(10.0); + buffer.Add(20.0); + buffer.Add(30.0); + buffer.Add(40.0); // Wraps + + // StartIndex should point to oldest element + Assert.True(buffer.StartIndex >= 0 && buffer.StartIndex < buffer.Capacity); + Assert.Equal(20.0, buffer.Oldest); + } + + [Fact] + public void Indexer_NegativeIndexViaFromEnd_ThrowsWhenOutOfBounds() + { + var buffer = new RingBuffer(5); + + buffer.Add(10.0); + buffer.Add(20.0); + buffer.Add(30.0); + + // ^4 when count=3 should throw + Assert.Throws(() => _ = buffer[^4]); + } + + [Fact] + public void CopyTo_InsufficientDestinationBuffer_Behavior() + { + var buffer = new RingBuffer(5); + + buffer.Add(10.0); + buffer.Add(20.0); + buffer.Add(30.0); + + var dest = new double[2]; // Too small + + // This will throw IndexOutOfRangeException since we're copying 3 elements to size-2 array + Assert.Throws(() => buffer.CopyTo(dest, 0)); + } + + [Fact] + public void CopyTo_StartIndexOutOfRange_Behavior() + { + var buffer = new RingBuffer(5); + + buffer.Add(10.0); + buffer.Add(20.0); + + var dest = new double[5]; + + // Starting at index 4 with 2 elements should fail + Assert.Throws(() => buffer.CopyTo(dest, 4)); + } } diff --git a/lib/core/simd/SimdExtensions.Tests.cs b/lib/core/simd/SimdExtensions.Tests.cs index aa17fe5b..bf27ee1f 100644 --- a/lib/core/simd/SimdExtensions.Tests.cs +++ b/lib/core/simd/SimdExtensions.Tests.cs @@ -805,4 +805,191 @@ public class SimdScalarFallbackTests Assert.Equal(42.5, min); Assert.Equal(42.5, max); } + + // Additional edge case tests + [Fact] + public void DotProduct_ContainsNaN_PropagatesNaN() + { + double[] a = [1.0, double.NaN, 3.0]; + double[] b = [4.0, 5.0, 6.0]; + double result = SimdExtensions.DotProduct(a, b); + Assert.True(double.IsNaN(result)); + } + + [Fact] + public void DotProduct_ContainsInfinity_PropagatesCorrectly() + { + double[] a = [1.0, double.PositiveInfinity, 3.0]; + double[] b = [4.0, 5.0, 6.0]; + double result = SimdExtensions.DotProduct(a, b); + Assert.True(double.IsPositiveInfinity(result)); + } + + [Fact] + public void Add_ContainsNaN_PropagatesNaN() + { + double[] left = [1.0, double.NaN, 3.0]; + double[] right = [4.0, 5.0, 6.0]; + double[] result = new double[3]; + + SimdExtensions.Add(left, right, result); + + Assert.Equal(5.0, result[0]); + Assert.True(double.IsNaN(result[1])); + Assert.Equal(9.0, result[2]); + } + + [Fact] + public void Subtract_ContainsNaN_PropagatesNaN() + { + double[] left = [10.0, double.NaN, 30.0]; + double[] right = [1.0, 2.0, 3.0]; + double[] result = new double[3]; + + SimdExtensions.Subtract(left, right, result); + + Assert.Equal(9.0, result[0]); + Assert.True(double.IsNaN(result[1])); + Assert.Equal(27.0, result[2]); + } + + [Fact] + public void ContainsNonFinite_NegativeInfinityAtStart_ReturnsTrue() + { + double[] data = [double.NegativeInfinity, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]; + var span = new ReadOnlySpan(data); + Assert.True(span.ContainsNonFinite()); + } + + [Fact] + public void ContainsNonFinite_NegativeInfinityAtEnd_ReturnsTrue() + { + double[] data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, double.NegativeInfinity]; + var span = new ReadOnlySpan(data); + Assert.True(span.ContainsNonFinite()); + } + + [Fact] + public void VarianceSIMD_SingleElement_ReturnsNaN() + { + double[] data = [42.5]; + var span = new ReadOnlySpan(data); + Assert.True(double.IsNaN(span.VarianceSIMD())); + } + + [Fact] + public void StdDevSIMD_SingleElement_ReturnsNaN() + { + double[] data = [42.5]; + var span = new ReadOnlySpan(data); + Assert.True(double.IsNaN(span.StdDevSIMD())); + } + + [Fact] + public void StdDevSIMD_EmptySpan_ReturnsNaN() + { + var span = ReadOnlySpan.Empty; + Assert.True(double.IsNaN(span.StdDevSIMD())); + } + + [Fact] + public void SumSIMD_SingleElement_ReturnsElement() + { + double[] data = [42.5]; + var span = new ReadOnlySpan(data); + Assert.Equal(42.5, span.SumSIMD()); + } + + [Fact] + public void AverageSIMD_SingleElement_ReturnsElement() + { + double[] data = [42.5]; + var span = new ReadOnlySpan(data); + Assert.Equal(42.5, span.AverageSIMD()); + } + + [Fact] + public void DotProduct_SingleElement_ReturnsProduct() + { + double[] a = [3.0]; + double[] b = [4.0]; + Assert.Equal(12.0, SimdExtensions.DotProduct(a, b)); + } + + [Fact] + public void DotProduct_TwoElements_ReturnsCorrect() + { + double[] a = [2.0, 3.0]; + double[] b = [4.0, 5.0]; + // 2*4 + 3*5 = 8 + 15 = 23 + Assert.Equal(23.0, SimdExtensions.DotProduct(a, b)); + } + + [Fact] + public void Add_SingleElement_Works() + { + double[] left = [5.0]; + double[] right = [3.0]; + double[] result = new double[1]; + + SimdExtensions.Add(left, right, result); + + Assert.Equal(8.0, result[0]); + } + + [Fact] + public void Subtract_SingleElement_Works() + { + double[] left = [5.0]; + double[] right = [3.0]; + double[] result = new double[1]; + + SimdExtensions.Subtract(left, right, result); + + Assert.Equal(2.0, result[0]); + } + + [Fact] + public void Add_EmptyArrays_Works() + { + double[] left = []; + double[] right = []; + double[] result = []; + + SimdExtensions.Add(left, right, result); // Should not throw + + Assert.Empty(result); + } + + [Fact] + public void Subtract_EmptyArrays_Works() + { + double[] left = []; + double[] right = []; + double[] result = []; + + SimdExtensions.Subtract(left, right, result); // Should not throw + + Assert.Empty(result); + } + + [Fact] + public void Add_ResultTooSmall_ThrowsArgumentException() + { + double[] left = [1.0, 2.0, 3.0]; + double[] right = [4.0, 5.0, 6.0]; + double[] result = new double[2]; // Too small + + Assert.Throws(() => SimdExtensions.Add(left, right, result)); + } + + [Fact] + public void Subtract_ResultTooSmall_ThrowsArgumentException() + { + double[] left = [1.0, 2.0, 3.0]; + double[] right = [4.0, 5.0, 6.0]; + double[] result = new double[2]; // Too small + + Assert.Throws(() => SimdExtensions.Subtract(left, right, result)); + } } diff --git a/lib/core/tbar/TBar.Tests.cs b/lib/core/tbar/TBar.Tests.cs index 5843e433..f592868d 100644 --- a/lib/core/tbar/TBar.Tests.cs +++ b/lib/core/tbar/TBar.Tests.cs @@ -345,5 +345,156 @@ public class TBarTests var bar2 = new TBar(12346, 100, 110, 90, 105, 1000); Assert.True(bar1 != bar2); - } + } + + // Additional edge case tests + [Fact] + public void Constructor_WithLocalDateTime_ConvertsToUtc() + { + var localDateTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Local); + var bar = new TBar(localDateTime, 100, 110, 90, 105, 1000); + + // AsDateTime should return UTC + Assert.Equal(DateTimeKind.Utc, bar.AsDateTime.Kind); + Assert.Equal(localDateTime.ToUniversalTime().Ticks, bar.Time); + } + + [Fact] + public void Constructor_WithUnspecifiedDateTime_ConvertsToUtc() + { + var unspecifiedDateTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Unspecified); + var bar = new TBar(unspecifiedDateTime, 100, 110, 90, 105, 1000); + + // Should be converted to UTC + Assert.Equal(DateTimeKind.Utc, bar.AsDateTime.Kind); + } + + [Fact] + public void DefaultTBar_HasZeroValues() + { + var bar = default(TBar); + + Assert.Equal(0, bar.Time); + Assert.Equal(0.0, bar.Open); + Assert.Equal(0.0, bar.High); + Assert.Equal(0.0, bar.Low); + Assert.Equal(0.0, bar.Close); + Assert.Equal(0.0, bar.Volume); + } + + [Fact] + public void TBar_WithNaN_HandlesGracefully() + { + var bar = new TBar(12345, double.NaN, 110, 90, 105, 1000); + + Assert.True(double.IsNaN(bar.Open)); + Assert.True(double.IsNaN(bar.O.Value)); + Assert.True(double.IsNaN(bar.OHL3)); // Uses Open + Assert.True(double.IsNaN(bar.OC2)); // Uses Open + Assert.True(double.IsNaN(bar.OHLC4)); // Uses Open + } + + [Fact] + public void TBar_WithInfinity_HandlesGracefully() + { + var bar = new TBar(12345, 100, double.PositiveInfinity, 90, 105, 1000); + + Assert.True(double.IsPositiveInfinity(bar.High)); + Assert.True(double.IsPositiveInfinity(bar.H.Value)); + Assert.True(double.IsPositiveInfinity(bar.HL2)); // Uses High + } + + [Fact] + public void TBar_WithMaxValue_HandlesGracefully() + { + var bar = new TBar(12345, double.MaxValue, double.MaxValue, double.MinValue, 105, 1000); + + Assert.Equal(double.MaxValue, bar.Open); + Assert.Equal(double.MaxValue, bar.High); + Assert.Equal(double.MinValue, bar.Low); + // HL2 calculation with extreme values + Assert.True(double.IsFinite(bar.HL2) || double.IsInfinity(bar.HL2)); + } + + [Fact] + public void TBar_WithEpsilon_HandlesGracefully() + { + var bar = new TBar(12345, double.Epsilon, double.Epsilon, double.Epsilon, double.Epsilon, double.Epsilon); + + Assert.Equal(double.Epsilon, bar.Open); + Assert.Equal(double.Epsilon, bar.Close); + Assert.True(bar.HL2 > 0); + } + + [Fact] + public void HL2_WithNegativeValues_CalculatesCorrectly() + { + var bar = new TBar(0, -100, -90, -110, -95, 1000); + + Assert.Equal(-100.0, bar.HL2); // (-90 + -110) / 2 + } + + [Fact] + public void OHLC4_WithNegativeValues_CalculatesCorrectly() + { + var bar = new TBar(0, -100, -90, -110, -100, 1000); + + Assert.Equal(-100.0, bar.OHLC4); // (-100 + -90 + -110 + -100) / 4 + } + + [Fact] + public void ImplicitConversion_ToTValue_PreservesTimeAndClose() + { + long time = 12_345_678_901_234_567; + var bar = new TBar(time, 100, 110, 90, 105.5, 1000); + + TValue tv = bar; + + Assert.Equal(time, tv.Time); + Assert.Equal(105.5, tv.Value); + } + + [Fact] + public void ToString_WithNaN_DoesNotThrow() + { + var bar = new TBar(DateTime.UtcNow.Ticks, double.NaN, double.NaN, double.NaN, double.NaN, double.NaN); + + string result = bar.ToString(); + + Assert.NotNull(result); + Assert.Contains("NaN", result, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void O_H_L_C_V_AllHaveSameTime() + { + long time = DateTime.UtcNow.Ticks; + var bar = new TBar(time, 100, 110, 90, 105, 1000); + + Assert.Equal(time, bar.O.Time); + Assert.Equal(time, bar.H.Time); + Assert.Equal(time, bar.L.Time); + Assert.Equal(time, bar.C.Time); + Assert.Equal(time, bar.V.Time); + } + + [Fact] + public void HLCC4_DoubleWeightsClose() + { + // HLCC4 = (High + Low + Close + Close) / 4 + var bar = new TBar(0, 100, 120, 80, 100, 1000); + + // (120 + 80 + 100 + 100) / 4 = 400 / 4 = 100 + Assert.Equal(100.0, bar.HLCC4); + } + + [Fact] + public void OHL3_ExcludesClose() + { + // OHL3 = (Open + High + Low) / 3 + var bar = new TBar(0, 90, 120, 60, 999, 1000); + + // (90 + 120 + 60) / 3 = 270 / 3 = 90 + Assert.Equal(90.0, bar.OHL3); + } } diff --git a/lib/core/tbarseries/TBarSeries.Tests.cs b/lib/core/tbarseries/TBarSeries.Tests.cs index ec104998..df6a5708 100644 --- a/lib/core/tbarseries/TBarSeries.Tests.cs +++ b/lib/core/tbarseries/TBarSeries.Tests.cs @@ -344,7 +344,8 @@ public class TBarSeriesTests series.Add(200, 20, 25, 15, 22, 200, isNew: true); var list = new List(); - + +#pragma warning disable S4158 foreach (var item in (IEnumerable)series) { list.Add(item); @@ -408,4 +409,146 @@ public class TBarSeriesTests Assert.Equal(200, series[1].Time); Assert.Equal(300, series[2].Time); } + + [Fact] + public void Add_WithEnumerables_MismatchedLengths_ThrowsArgumentException() + { + var series = new TBarSeries(); + var times = new long[] { 100, 200, 300 }; + var opens = new double[] { 10, 20 }; // Mismatched length + var highs = new double[] { 15, 25, 35 }; + var lows = new double[] { 5, 15, 25 }; + var closes = new double[] { 12, 22, 32 }; + var volumes = new double[] { 100, 200, 300 }; + + Assert.Throws(() => + series.Add(times, opens, highs, lows, closes, volumes)); + } + + [Fact] + public void Indexer_OutOfBounds_ThrowsException() + { + var series = new TBarSeries(); + + Assert.Throws(() => _ = series[0]); + } + + [Fact] + public void Indexer_NegativeIndex_ThrowsException() + { + var series = new TBarSeries(); + series.Add(100, 10, 15, 5, 12, 100); + + int invalidIndex = -1; + Assert.Throws(() => _ = series[invalidIndex]); + } + + [Fact] + public void Indexer_BeyondCount_ThrowsException() + { + var series = new TBarSeries(); + series.Add(100, 10, 15, 5, 12, 100); + + Assert.Throws(() => _ = series[1]); + } + + [Fact] + public void Add_WithNaN_PreservesNaN() + { + var series = new TBarSeries(); + var bar = new TBar(DateTime.UtcNow.Ticks, double.NaN, 110, 90, 105, 1000); + + series.Add(bar, isNew: true); + + Assert.True(double.IsNaN(series.Last.Open)); + Assert.True(double.IsNaN(series.Open.Last.Value)); + } + + [Fact] + public void Add_WithInfinity_PreservesInfinity() + { + var series = new TBarSeries(); + var bar = new TBar(DateTime.UtcNow.Ticks, 100, double.PositiveInfinity, 90, 105, 1000); + + series.Add(bar, isNew: true); + + Assert.True(double.IsPositiveInfinity(series.Last.High)); + Assert.True(double.IsPositiveInfinity(series.High.Last.Value)); + } + + [Fact] + public void SubSeries_EmptySeries_HaveZeroCount() + { + var series = new TBarSeries(); + + Assert.Empty(series.Open); + Assert.Empty(series.High); + Assert.Empty(series.Low); + Assert.Empty(series.Close); + Assert.Empty(series.Volume); + } + + [Fact] + public void SubSeries_ValuesSpan_ReturnsCorrectData() + { + var series = new TBarSeries(); + series.Add(100, 10, 15, 5, 12, 100); + series.Add(200, 20, 25, 15, 22, 200); + + ReadOnlySpan closeValues = series.Close.Values; + + Assert.Equal(2, closeValues.Length); + Assert.Equal(12.0, closeValues[0]); + Assert.Equal(22.0, closeValues[1]); + } + + [Fact] + public void SubSeries_TimesSpan_ReturnsCorrectData() + { + var series = new TBarSeries(); + series.Add(100, 10, 15, 5, 12, 100); + series.Add(200, 20, 25, 15, 22, 200); + + ReadOnlySpan times = series.Close.Times; + + Assert.Equal(2, times.Length); + Assert.Equal(100, times[0]); + Assert.Equal(200, times[1]); + } + + [Fact] + public void Pub_EventArgs_ContainsIsNewFlag() + { + var series = new TBarSeries(); + bool? receivedIsNew = null; + series.Pub += (object? sender, in TBarEventArgs args) => receivedIsNew = args.IsNew; + + series.Add(new TBar(100, 10, 15, 5, 12, 100), isNew: true); + + Assert.True(receivedIsNew); + + series.Add(new TBar(100, 10, 18, 5, 15, 150), isNew: false); + + Assert.False(receivedIsNew); + } + + [Fact] + public void Add_WithEnumerables_EmptyArrays_AddsNothing() + { + var series = new TBarSeries(); + var empty = Array.Empty(); + var emptyD = Array.Empty(); + + series.Add(empty, emptyD, emptyD, emptyD, emptyD, emptyD); + + Assert.Empty(series); + } + + [Fact] + public void Constructor_WithCapacity_DoesNotAffectCount() + { + var series = new TBarSeries(1000); + + Assert.Empty(series); + } } diff --git a/lib/core/tbarseries/tbarseries.cs b/lib/core/tbarseries/tbarseries.cs index 4bb8863b..81775671 100644 --- a/lib/core/tbarseries/tbarseries.cs +++ b/lib/core/tbarseries/tbarseries.cs @@ -1,5 +1,6 @@ using System; using System.Collections; +using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; @@ -16,6 +17,74 @@ public readonly struct TBarEventArgs public bool IsNew { get; init; } } +/// +/// High-performance enumerator for TBarSeries. +/// +public struct TBarSeriesEnumerator : IEnumerator, IEquatable +{ + private readonly List _t; + private readonly List _o; + private readonly List _h; + private readonly List _l; + private readonly List _c; + private readonly List _v; + private readonly int _count; + private int _index; + private TBar _current; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal TBarSeriesEnumerator(List t, List o, List h, List l, List c, List v) + { + _t = t; + _o = o; + _h = h; + _l = l; + _c = c; + _v = v; + _count = c.Count; + _index = -1; + _current = default; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool MoveNext() + { + if (_index + 1 >= _count) + return false; + + _index++; + _current = new TBar(_t[_index], _o[_index], _h[_index], _l[_index], _c[_index], _v[_index]); + return true; + } + + public readonly TBar Current => _current; + readonly object IEnumerator.Current => Current; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Reset() + { + _index = -1; + _current = default; + } + + public readonly void Dispose() { } + + public readonly bool Equals(TBarSeriesEnumerator other) => + ReferenceEquals(_t, other._t) && + ReferenceEquals(_c, other._c) && + _count == other._count && + _index == other._index; + + public override readonly bool Equals(object? obj) => + obj is TBarSeriesEnumerator other && Equals(other); + + public override readonly int GetHashCode() => + HashCode.Combine(RuntimeHelpers.GetHashCode(_t), RuntimeHelpers.GetHashCode(_c), _count, _index); + + public static bool operator ==(TBarSeriesEnumerator left, TBarSeriesEnumerator right) => left.Equals(right); + public static bool operator !=(TBarSeriesEnumerator left, TBarSeriesEnumerator right) => !left.Equals(right); +} + // Performance-focused event args struct; not derived from EventArgs by design. // We intentionally deviate from the standard EventArgs pattern here for perf. #pragma warning disable MA0046 // The second parameter must be of type 'System.EventArgs' or a derived type @@ -94,6 +163,60 @@ public class TBarSeries : IReadOnlyList public double LastClose { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => _c.Count > 0 ? _c[^1] : double.NaN; } public double LastVolume { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => _v.Count > 0 ? _v[^1] : double.NaN; } + /// + /// Direct access to the underlying Time array as a Span. + /// + public ReadOnlySpan Times + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => CollectionsMarshal.AsSpan(_t); + } + + /// + /// Direct access to the underlying Open array as a Span for SIMD operations. + /// + public ReadOnlySpan OpenValues + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => CollectionsMarshal.AsSpan(_o); + } + + /// + /// Direct access to the underlying High array as a Span for SIMD operations. + /// + public ReadOnlySpan HighValues + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => CollectionsMarshal.AsSpan(_h); + } + + /// + /// Direct access to the underlying Low array as a Span for SIMD operations. + /// + public ReadOnlySpan LowValues + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => CollectionsMarshal.AsSpan(_l); + } + + /// + /// Direct access to the underlying Close array as a Span for SIMD operations. + /// + public ReadOnlySpan CloseValues + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => CollectionsMarshal.AsSpan(_c); + } + + /// + /// Direct access to the underlying Volume array as a Span for SIMD operations. + /// + public ReadOnlySpan VolumeValues + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => CollectionsMarshal.AsSpan(_v); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Add(TBar bar, bool isNew = true) { @@ -150,13 +273,10 @@ public class TBarSeries : IReadOnlyList } } - public IEnumerator GetEnumerator() - { - for (int i = 0; i < _c.Count; i++) - { - yield return new TBar(_t[i], _o[i], _h[i], _l[i], _c[i], _v[i]); - } - } + // IEnumerable implementation with struct enumerator for zero-allocation iteration + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public TBarSeriesEnumerator GetEnumerator() => new(_t, _o, _h, _l, _c, _v); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); } diff --git a/lib/core/tseries/TSeries.Tests.cs b/lib/core/tseries/TSeries.Tests.cs index 5a1c065c..72dcd719 100644 --- a/lib/core/tseries/TSeries.Tests.cs +++ b/lib/core/tseries/TSeries.Tests.cs @@ -285,11 +285,14 @@ public class TSeriesTests series.Add(200, 2.0); var list = new List(); - foreach (var item in (IEnumerable)series) + IEnumerable enumerable = series; +#pragma warning disable S4158 + foreach (var item in enumerable) { list.Add(item); } + Assert.Equal(2, series.Count); Assert.Equal(2, list.Count); } @@ -332,4 +335,204 @@ public class TSeriesTests series.Add(200, 2.0); Assert.Equal(2, series.Count); } + + [Fact] + public void Constructor_WithMismatchedLists_WrapsData() + { + // TSeries wraps the lists directly if they're List, no length validation + var times = new List { 100, 200, 300 }; + var values = new List { 1.0, 2.0 }; // Different length + + var series = new TSeries(times, values); + + // Count is based on values list + Assert.Equal(2, series.Count); + } + + [Fact] + public void Indexer_OutOfBounds_ThrowsException() + { + var series = new TSeries(); + + Assert.Throws(() => _ = series[0]); + } + + [Fact] + public void Indexer_NegativeIndex_ThrowsException() + { + var series = new TSeries(); + series.Add(100, 1.0); + +#pragma warning disable DS003 // Invalid index - intentional for testing exception + Assert.Throws(() => _ = series[-1]); +#pragma warning restore DS003 + } + + [Fact] + public void Indexer_BeyondCount_ThrowsException() + { + var series = new TSeries(); + series.Add(100, 1.0); + + Assert.Throws(() => _ = series[1]); + } + + [Fact] + public void Values_EmptySeries_ReturnsEmptySpan() + { + var series = new TSeries(); + + ReadOnlySpan values = series.Values; + + Assert.Equal(0, values.Length); + } + + [Fact] + public void Times_EmptySeries_ReturnsEmptySpan() + { + var series = new TSeries(); + + ReadOnlySpan times = series.Times; + + Assert.Equal(0, times.Length); + } + + [Fact] + public void Add_WithNaN_PreservesNaN() + { + var series = new TSeries(); + + series.Add(100, double.NaN); + + Assert.True(double.IsNaN(series.Last.Value)); + Assert.True(double.IsNaN(series.LastValue)); + } + + [Fact] + public void Add_WithInfinity_PreservesInfinity() + { + var series = new TSeries(); + + series.Add(100, double.PositiveInfinity); + + Assert.True(double.IsPositiveInfinity(series.Last.Value)); + Assert.True(double.IsPositiveInfinity(series.LastValue)); + } + + [Fact] + public void Add_WithNegativeInfinity_PreservesNegativeInfinity() + { + var series = new TSeries(); + + series.Add(100, double.NegativeInfinity); + + Assert.True(double.IsNegativeInfinity(series.Last.Value)); + } + + [Fact] + public void Add_EnumerableDoubles_GeneratesIncreasingTimes() + { + var series = new TSeries(); + var values = new[] { 1.0, 2.0, 3.0 }; + + series.Add(values); + + Assert.Equal(3, series.Count); + // Times should be increasing by TicksPerMinute + Assert.True(series[1].Time > series[0].Time); + Assert.True(series[2].Time > series[1].Time); + Assert.Equal(TimeSpan.TicksPerMinute, series[1].Time - series[0].Time); + } + + [Fact] + public void Add_EnumerableDoubles_EmptyArray_AddsNothing() + { + var series = new TSeries(); + + series.Add(Array.Empty()); + + Assert.Empty(series); + } + + [Fact] + public void Pub_EventArgs_ContainsIsNewFlag() + { + var series = new TSeries(); + bool? receivedIsNew = null; + series.Pub += (object? sender, in TValueEventArgs args) => receivedIsNew = args.IsNew; + + series.Add(new TValue(100, 42.0), isNew: true); + + Assert.True(receivedIsNew); + + series.Add(new TValue(100, 43.0), isNew: false); + + Assert.False(receivedIsNew); + } + + [Fact] + public void Constructor_WithCapacity_DoesNotAffectCount() + { + var series = new TSeries(1000); + + Assert.Empty(series); + } + + [Fact] + public void Add_WithDateTimeLocal_ConvertsToUtc() + { + var series = new TSeries(); + var localTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Local); + + series.Add(localTime, 100.0); + + // The stored time should be UTC + var storedTime = new DateTime(series.Last.Time, DateTimeKind.Utc); + Assert.Equal(DateTimeKind.Utc, storedTime.Kind); + } + + [Fact] + public void Add_WithDateTimeUnspecified_TreatsAsLocal() + { + var series = new TSeries(); + var unspecifiedTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Unspecified); + + series.Add(unspecifiedTime, 100.0); + + Assert.Single(series); + } + + [Fact] + public void Values_ModifyingUnderlyingList_ReflectsInSpan() + { + var series = new TSeries(); + series.Add(100, 1.0); + series.Add(200, 2.0); + + // Get the span + ReadOnlySpan values1 = series.Values; + Assert.Equal(2, values1.Length); + + // Add more data + series.Add(300, 3.0); + + // Get new span - should reflect the change + ReadOnlySpan values2 = series.Values; + Assert.Equal(3, values2.Length); + Assert.Equal(3.0, values2[2]); + } + + [Fact] + public void Constructor_WithReadOnlyLists_CopiesData() + { + // Using arrays which implement IReadOnlyList but aren't List + IReadOnlyList times = new long[] { 100, 200, 300 }; + IReadOnlyList values = [1.0, 2.0, 3.0]; + + var series = new TSeries(times, values); + + Assert.Equal(3, series.Count); + Assert.Equal(1.0, series[0].Value); + Assert.Equal(3.0, series[2].Value); + } } diff --git a/lib/core/tseries/tseries.cs b/lib/core/tseries/tseries.cs index 6d8545e8..dcc36c9a 100644 --- a/lib/core/tseries/tseries.cs +++ b/lib/core/tseries/tseries.cs @@ -6,6 +6,66 @@ using System.Runtime.InteropServices; namespace QuanTAlib; +/// +/// High-performance enumerator for TSeries. +/// +public struct TSeriesEnumerator : IEnumerator, IEquatable +{ + private readonly List _t; + private readonly List _v; + private readonly int _count; + private int _index; + private TValue _current; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal TSeriesEnumerator(List t, List v) + { + _t = t; + _v = v; + _count = v.Count; + _index = -1; + _current = default; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool MoveNext() + { + if (_index + 1 >= _count) + return false; + + _index++; + _current = new TValue(_t[_index], _v[_index]); + return true; + } + + public readonly TValue Current => _current; + readonly object IEnumerator.Current => Current; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Reset() + { + _index = -1; + _current = default; + } + + public readonly void Dispose() { } + + public readonly bool Equals(TSeriesEnumerator other) => + ReferenceEquals(_t, other._t) && + ReferenceEquals(_v, other._v) && + _count == other._count && + _index == other._index; + + public override readonly bool Equals(object? obj) => + obj is TSeriesEnumerator other && Equals(other); + + public override readonly int GetHashCode() => + HashCode.Combine(RuntimeHelpers.GetHashCode(_t), RuntimeHelpers.GetHashCode(_v), _count, _index); + + public static bool operator ==(TSeriesEnumerator left, TSeriesEnumerator right) => left.Equals(right); + public static bool operator !=(TSeriesEnumerator left, TSeriesEnumerator right) => !left.Equals(right); +} + /// /// A high-performance time series implementation using Structure of Arrays (SoA) layout. /// Stores Time (long) and Value (double) in separate contiguous arrays for SIMD efficiency. @@ -132,14 +192,10 @@ public class TSeries : IReadOnlyList, ITValuePublisher } } - // IEnumerable implementation - public IEnumerator GetEnumerator() - { - for (int i = 0; i < _v.Count; i++) - { - yield return new TValue(_t[i], _v[i]); - } - } + // IEnumerable implementation with struct enumerator for zero-allocation iteration + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public TSeriesEnumerator GetEnumerator() => new(_t, _v); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); } diff --git a/lib/core/tvalue/TValue.Tests.cs b/lib/core/tvalue/TValue.Tests.cs index cf38b587..fe3874d6 100644 --- a/lib/core/tvalue/TValue.Tests.cs +++ b/lib/core/tvalue/TValue.Tests.cs @@ -1,182 +1,372 @@ +namespace QuanTAlib.Tests; -namespace QuanTAlib.Tests +public class TValueTests { - public class TValueTests + [Fact] + public void Constructor_WithLongTime_SetsPropertiesCorrectly() { - [Fact] - public void Constructor_WithLongTime_SetsPropertiesCorrectly() - { - long time = DateTime.UtcNow.Ticks; - double value = 123.45; + long time = DateTime.UtcNow.Ticks; + double value = 123.45; - var tValue = new TValue(time, value); + var tValue = new TValue(time, value); - Assert.Equal(time, tValue.Time); - Assert.Equal(value, tValue.Value); - } + Assert.Equal(time, tValue.Time); + Assert.Equal(value, tValue.Value); + } - [Fact] - public void Constructor_WithDateTime_SetsPropertiesCorrectly() - { - var dateTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Utc); - double value = 123.45; + [Fact] + public void Constructor_WithDateTime_SetsPropertiesCorrectly() + { + var dateTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Utc); + double value = 123.45; - var tValue = new TValue(dateTime, value); + var tValue = new TValue(dateTime, value); - Assert.Equal(dateTime.Ticks, tValue.Time); - Assert.Equal(value, tValue.Value); - } + Assert.Equal(dateTime.Ticks, tValue.Time); + Assert.Equal(value, tValue.Value); + } - [Fact] - public void AsDateTime_ReturnsCorrectDateTime() - { - var dt = new DateTime(2023, 1, 1, 12, 0, 0, DateTimeKind.Utc); - var tValue = new TValue(dt.Ticks, 100.0); + [Fact] + public void AsDateTime_ReturnsCorrectDateTime() + { + var dt = new DateTime(2023, 1, 1, 12, 0, 0, DateTimeKind.Utc); + var tValue = new TValue(dt.Ticks, 100.0); - Assert.Equal(dt, tValue.AsDateTime); - Assert.Equal(DateTimeKind.Utc, tValue.AsDateTime.Kind); - } + Assert.Equal(dt, tValue.AsDateTime); + Assert.Equal(DateTimeKind.Utc, tValue.AsDateTime.Kind); + } - [Fact] - public void ToString_FormatsCorrectly() - { - var dt = new DateTime(2023, 1, 1, 12, 0, 0, DateTimeKind.Utc); - var tValue = new TValue(dt.Ticks, 123.456); + [Fact] + public void ToString_FormatsCorrectly() + { + var dt = new DateTime(2023, 1, 1, 12, 0, 0, DateTimeKind.Utc); + var tValue = new TValue(dt.Ticks, 123.456); - string result = tValue.ToString(); + string result = tValue.ToString(); - Assert.Contains("2023-01-01", result, StringComparison.Ordinal); - Assert.Contains("12:00:00", result, StringComparison.Ordinal); - Assert.Contains("123.46", result, StringComparison.Ordinal); - } + Assert.Contains("2023-01-01", result, StringComparison.Ordinal); + Assert.Contains("12:00:00", result, StringComparison.Ordinal); + Assert.Contains("123.46", result, StringComparison.Ordinal); + } - [Fact] - public void ImplicitConversion_ToDouble_ReturnsValue() - { - var tValue = new TValue(DateTime.UtcNow.Ticks, 42.0); + [Fact] + public void ImplicitConversion_ToDouble_ReturnsValue() + { + var tValue = new TValue(DateTime.UtcNow.Ticks, 42.0); - double val = tValue; + double val = tValue; - Assert.Equal(42.0, val); - } + Assert.Equal(42.0, val); + } - [Fact] - public void ImplicitConversion_ToDateTime_ReturnsCorrectDateTime() - { - var dateTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Utc); - var tValue = new TValue(dateTime.Ticks, 100.0); + [Fact] + public void ImplicitConversion_ToDateTime_ReturnsCorrectDateTime() + { + var dateTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Utc); + var tValue = new TValue(dateTime.Ticks, 100.0); - DateTime result = tValue; + DateTime result = tValue; - Assert.Equal(dateTime, result); - Assert.Equal(DateTimeKind.Utc, result.Kind); - } + Assert.Equal(dateTime, result); + Assert.Equal(DateTimeKind.Utc, result.Kind); + } - [Fact] - public void Equals_TValue_SameValues_ReturnsTrue() - { - var tv1 = new TValue(12345, 100.0); - var tv2 = new TValue(12345, 100.0); + [Fact] + public void Equals_TValue_SameValues_ReturnsTrue() + { + var tv1 = new TValue(12345, 100.0); + var tv2 = new TValue(12345, 100.0); - Assert.True(tv1.Equals(tv2)); - } + Assert.True(tv1.Equals(tv2)); + } - [Fact] - public void Equals_TValue_DifferentTime_ReturnsFalse() - { - var tv1 = new TValue(12345, 100.0); - var tv2 = new TValue(12346, 100.0); + [Fact] + public void Equals_TValue_DifferentTime_ReturnsFalse() + { + var tv1 = new TValue(12345, 100.0); + var tv2 = new TValue(12346, 100.0); - Assert.False(tv1.Equals(tv2)); - } + Assert.False(tv1.Equals(tv2)); + } - [Fact] - public void Equals_TValue_DifferentValue_ReturnsFalse() - { - var tv1 = new TValue(12345, 100.0); - var tv2 = new TValue(12345, 101.0); + [Fact] + public void Equals_TValue_DifferentValue_ReturnsFalse() + { + var tv1 = new TValue(12345, 100.0); + var tv2 = new TValue(12345, 101.0); - Assert.False(tv1.Equals(tv2)); - } + Assert.False(tv1.Equals(tv2)); + } - [Fact] - public void Equals_Object_SameTValue_ReturnsTrue() - { - var tv1 = new TValue(12345, 100.0); - object tv2 = new TValue(12345, 100.0); + [Fact] + public void Equals_Object_SameTValue_ReturnsTrue() + { + var tv1 = new TValue(12345, 100.0); + object tv2 = new TValue(12345, 100.0); - Assert.True(tv1.Equals(tv2)); - } + Assert.True(tv1.Equals(tv2)); + } - [Fact] - public void Equals_Object_DifferentType_ReturnsFalse() - { - var tv = new TValue(12345, 100.0); - object other = "not a TValue"; + [Fact] + public void Equals_Object_DifferentType_ReturnsFalse() + { + var tv = new TValue(12345, 100.0); + object other = "not a TValue"; - Assert.False(tv.Equals(other)); - } + Assert.False(tv.Equals(other)); + } - [Fact] - public void Equals_Object_Null_ReturnsFalse() - { - var tv = new TValue(12345, 100.0); + [Fact] + public void Equals_Object_Null_ReturnsFalse() + { + var tv = new TValue(12345, 100.0); - Assert.False(tv.Equals(null)); - } + Assert.False(tv.Equals(null)); + } - [Fact] - public void GetHashCode_SameValues_ReturnsSameHashCode() - { - var tv1 = new TValue(12345, 100.0); - var tv2 = new TValue(12345, 100.0); + [Fact] + public void GetHashCode_SameValues_ReturnsSameHashCode() + { + var tv1 = new TValue(12345, 100.0); + var tv2 = new TValue(12345, 100.0); - Assert.Equal(tv1.GetHashCode(), tv2.GetHashCode()); - } + Assert.Equal(tv1.GetHashCode(), tv2.GetHashCode()); + } - [Fact] - public void GetHashCode_DifferentValues_ReturnsDifferentHashCode() - { - var tv1 = new TValue(12345, 100.0); - var tv2 = new TValue(12346, 100.0); + [Fact] + public void GetHashCode_DifferentValues_ReturnsDifferentHashCode() + { + var tv1 = new TValue(12345, 100.0); + var tv2 = new TValue(12346, 100.0); - Assert.NotEqual(tv1.GetHashCode(), tv2.GetHashCode()); - } + Assert.NotEqual(tv1.GetHashCode(), tv2.GetHashCode()); + } - [Fact] - public void EqualityOperator_SameValues_ReturnsTrue() - { - var tv1 = new TValue(12345, 100.0); - var tv2 = new TValue(12345, 100.0); + [Fact] + public void EqualityOperator_SameValues_ReturnsTrue() + { + var tv1 = new TValue(12345, 100.0); + var tv2 = new TValue(12345, 100.0); - Assert.True(tv1 == tv2); - } + Assert.True(tv1 == tv2); + } - [Fact] - public void EqualityOperator_DifferentValues_ReturnsFalse() - { - var tv1 = new TValue(12345, 100.0); - var tv2 = new TValue(12346, 100.0); + [Fact] + public void EqualityOperator_DifferentValues_ReturnsFalse() + { + var tv1 = new TValue(12345, 100.0); + var tv2 = new TValue(12346, 100.0); - Assert.False(tv1 == tv2); - } + Assert.False(tv1 == tv2); + } - [Fact] - public void InequalityOperator_SameValues_ReturnsFalse() - { - var tv1 = new TValue(12345, 100.0); - var tv2 = new TValue(12345, 100.0); + [Fact] + public void InequalityOperator_SameValues_ReturnsFalse() + { + var tv1 = new TValue(12345, 100.0); + var tv2 = new TValue(12345, 100.0); - Assert.False(tv1 != tv2); - } + Assert.False(tv1 != tv2); + } - [Fact] - public void InequalityOperator_DifferentValues_ReturnsTrue() - { - var tv1 = new TValue(12345, 100.0); - var tv2 = new TValue(12346, 100.0); + [Fact] + public void InequalityOperator_DifferentValues_ReturnsTrue() + { + var tv1 = new TValue(12345, 100.0); + var tv2 = new TValue(12346, 100.0); - Assert.True(tv1 != tv2); - } + Assert.True(tv1 != tv2); + } + + [Fact] + public void Constructor_WithDateTimeLocal_ConvertsToUtc() + { + var localTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Local); + double value = 123.45; + + var tValue = new TValue(localTime, value); + + // Time should be stored as UTC ticks + var expectedUtc = localTime.ToUniversalTime(); + Assert.Equal(expectedUtc.Ticks, tValue.Time); + } + + [Fact] + public void Constructor_WithDateTimeUnspecified_ConvertsToUtc() + { + var unspecifiedTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Unspecified); + double value = 123.45; + + var tValue = new TValue(unspecifiedTime, value); + + // Unspecified is treated as local and converted to UTC + var expectedUtc = unspecifiedTime.ToUniversalTime(); + Assert.Equal(expectedUtc.Ticks, tValue.Time); + } + + [Fact] + public void Constructor_WithDateTimeUtc_PreservesTicks() + { + var utcTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Utc); + double value = 123.45; + + var tValue = new TValue(utcTime, value); + + Assert.Equal(utcTime.Ticks, tValue.Time); + } + + [Fact] + public void Default_TValue_HasZeroTimeAndValue() + { + var defaultTValue = default(TValue); + + Assert.Equal(0, defaultTValue.Time); + Assert.Equal(0.0, defaultTValue.Value); + } + + [Fact] + public void Constructor_WithNaN_PreservesNaN() + { + var tValue = new TValue(DateTime.UtcNow.Ticks, double.NaN); + + Assert.True(double.IsNaN(tValue.Value)); + } + + [Fact] + public void Constructor_WithPositiveInfinity_PreservesInfinity() + { + var tValue = new TValue(DateTime.UtcNow.Ticks, double.PositiveInfinity); + + Assert.True(double.IsPositiveInfinity(tValue.Value)); + } + + [Fact] + public void Constructor_WithNegativeInfinity_PreservesInfinity() + { + var tValue = new TValue(DateTime.UtcNow.Ticks, double.NegativeInfinity); + + Assert.True(double.IsNegativeInfinity(tValue.Value)); + } + + [Fact] + public void Constructor_WithMaxValue_PreservesMaxValue() + { + var tValue = new TValue(DateTime.UtcNow.Ticks, double.MaxValue); + + Assert.Equal(double.MaxValue, tValue.Value); + } + + [Fact] + public void Constructor_WithMinValue_PreservesMinValue() + { + var tValue = new TValue(DateTime.UtcNow.Ticks, double.MinValue); + + Assert.Equal(double.MinValue, tValue.Value); + } + + [Fact] + public void Constructor_WithEpsilon_PreservesEpsilon() + { + var tValue = new TValue(DateTime.UtcNow.Ticks, double.Epsilon); + + Assert.Equal(double.Epsilon, tValue.Value); + } + + [Fact] + public void ImplicitConversion_ToDouble_WithNaN_ReturnsNaN() + { + var tValue = new TValue(DateTime.UtcNow.Ticks, double.NaN); + + double val = tValue; + + Assert.True(double.IsNaN(val)); + } + + [Fact] + public void ToString_WithNaN_FormatsCorrectly() + { + var dt = new DateTime(2023, 1, 1, 12, 0, 0, DateTimeKind.Utc); + var tValue = new TValue(dt.Ticks, double.NaN); + + string result = tValue.ToString(); + + Assert.Contains("NaN", result, StringComparison.Ordinal); + } + + [Fact] + public void ToString_WithInfinity_FormatsCorrectly() + { + var dt = new DateTime(2023, 1, 1, 12, 0, 0, DateTimeKind.Utc); + var tValue = new TValue(dt.Ticks, double.PositiveInfinity); + + string result = tValue.ToString(); + + Assert.Contains("∞", result, StringComparison.Ordinal); + } + + [Fact] + public void ToString_WithNegativeValue_FormatsCorrectly() + { + var dt = new DateTime(2023, 1, 1, 12, 0, 0, DateTimeKind.Utc); + var tValue = new TValue(dt.Ticks, -123.456); + + string result = tValue.ToString(); + + Assert.Contains("-123.46", result, StringComparison.Ordinal); + } + + [Fact] + public void AsDateTime_ReturnsUtcKind() + { + var tValue = new TValue(DateTime.UtcNow.Ticks, 100.0); + + Assert.Equal(DateTimeKind.Utc, tValue.AsDateTime.Kind); + } + + [Fact] + public void Equals_WithNaN_BothNaN_ReturnsFalse() + { + // NaN != NaN in IEEE 754 + var tv1 = new TValue(12345, double.NaN); + var tv2 = new TValue(12345, double.NaN); + + // Record struct equality compares fields directly + // double.NaN.Equals(double.NaN) returns true in .NET + Assert.True(tv1.Equals(tv2)); + } + + [Fact] + public void GetHashCode_WithNaN_DoesNotThrow() + { + var tv = new TValue(12345, double.NaN); + + var hash = tv.GetHashCode(); + + Assert.True(hash != 0 || hash == 0); // Just verify it doesn't throw + } + + [Fact] + public void Constructor_WithZeroTime_Allowed() + { + var tValue = new TValue(0, 100.0); + + Assert.Equal(0, tValue.Time); + Assert.Equal(100.0, tValue.Value); + } + + [Fact] + public void Constructor_WithNegativeTime_Allowed() + { + var tValue = new TValue(-12345, 100.0); + + Assert.Equal(-12345, tValue.Time); + } + + [Fact] + public void Constructor_WithMaxLongTime_Allowed() + { + var tValue = new TValue(long.MaxValue, 100.0); + + Assert.Equal(long.MaxValue, tValue.Time); } } diff --git a/lib/feeds/csv/CsvFeed.Tests.cs b/lib/feeds/csv/CsvFeed.Tests.cs index e8750dae..5a4bbbae 100644 --- a/lib/feeds/csv/CsvFeed.Tests.cs +++ b/lib/feeds/csv/CsvFeed.Tests.cs @@ -1,15 +1,42 @@ namespace QuanTAlib.Tests; -public class CsvFeedTests +public sealed class CsvFeedTests : IDisposable { private const string TestCsvPath = "daily_IBM.csv"; + private readonly List _tempFiles = new(); + private bool _disposed; + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + foreach (var file in _tempFiles) + { + if (File.Exists(file)) + { + try { File.Delete(file); } catch { /* ignore */ } + } + } + } + + private string CreateTempCsv(string[] lines) + { + string tempPath = Path.GetTempFileName() + ".csv"; + File.WriteAllLines(tempPath, lines); + _tempFiles.Add(tempPath); + return tempPath; + } + + #region Constructor Tests [Fact] public void Constructor_ValidFile_LoadsData() { var feed = new CsvFeed(TestCsvPath); Assert.NotNull(feed); + Assert.True(feed.Count > 0); } [Fact] @@ -21,15 +48,185 @@ public class CsvFeedTests [Fact] public void Constructor_NullPath_ThrowsArgumentException() { - Assert.Throws(() => new CsvFeed(null!)); + var ex = Assert.Throws(() => new CsvFeed(null!)); + Assert.Equal("filePath", ex.ParamName); } [Fact] public void Constructor_EmptyPath_ThrowsArgumentException() { - Assert.Throws(() => new CsvFeed("")); + var ex = Assert.Throws(() => new CsvFeed("")); + Assert.Equal("filePath", ex.ParamName); } + [Fact] + public void Constructor_WhitespacePath_ThrowsArgumentException() + { + var ex = Assert.Throws(() => new CsvFeed(" ")); + Assert.Equal("filePath", ex.ParamName); + } + + [Fact] + public void Constructor_EmptyCsv_ThrowsInvalidDataException() + { + string tempCsv = CreateTempCsv(Array.Empty()); + Assert.Throws(() => new CsvFeed(tempCsv)); + } + + [Fact] + public void Constructor_HeaderOnlyCsv_ThrowsInvalidDataException() + { + string tempCsv = CreateTempCsv(new[] { "timestamp,open,high,low,close,volume" }); + Assert.Throws(() => new CsvFeed(tempCsv)); + } + + [Fact] + public void Constructor_MalformedDate_ThrowsFormatException() + { + string tempCsv = CreateTempCsv(new[] + { + "timestamp,open,high,low,close,volume", + "not-a-date,100,101,99,100,1000" + }); + Assert.Throws(() => new CsvFeed(tempCsv)); + } + + [Fact] + public void Constructor_MalformedPrice_ThrowsFormatException() + { + string tempCsv = CreateTempCsv(new[] + { + "timestamp,open,high,low,close,volume", + "2023-01-01,not-a-number,101,99,100,1000" + }); + Assert.Throws(() => new CsvFeed(tempCsv)); + } + + [Fact] + public void Constructor_MissingColumns_ThrowsFormatException() + { + string tempCsv = CreateTempCsv(new[] + { + "timestamp,open,high,low,close,volume", + "2023-01-01,100,101,99,100" // Missing volume + }); + Assert.Throws(() => new CsvFeed(tempCsv)); + } + + [Fact] + public void Constructor_ExtraColumns_ThrowsFormatException() + { + // Extra columns should throw format exception (strict 6-column format) + string tempCsv = CreateTempCsv(new[] + { + "timestamp,open,high,low,close,volume,extra", + "2023-01-01,100,101,99,100,1000,extra_data" + }); + Assert.Throws(() => new CsvFeed(tempCsv)); + } + + #endregion + + #region Property Tests + + [Fact] + public void Count_ReturnsCorrectNumber() + { + var feed = new CsvFeed(TestCsvPath); + Assert.True(feed.Count > 0); + // IBM CSV has 100 rows of data + Assert.Equal(100, feed.Count); + } + + [Fact] + public void FilePath_ReturnsLoadedPath() + { + var feed = new CsvFeed(TestCsvPath); + Assert.Equal(TestCsvPath, feed.FilePath); + } + + [Fact] + public void HasMore_TrueAtStart() + { + var feed = new CsvFeed(TestCsvPath); + Assert.True(feed.HasMore); + } + + [Fact] + public void HasMore_FalseWhenExhausted() + { + string tempCsv = CreateTempCsv(new[] + { + "timestamp,open,high,low,close,volume", + "2023-01-01,100,101,99,100,1000" + }); + var feed = new CsvFeed(tempCsv); + + Assert.True(feed.HasMore); + feed.Next(isNew: true); + Assert.False(feed.HasMore); + } + + [Fact] + public void CurrentIndex_StartsAtZero() + { + var feed = new CsvFeed(TestCsvPath); + Assert.Equal(0, feed.CurrentIndex); + } + + [Fact] + public void CurrentIndex_IncrementsOnNext() + { + var feed = new CsvFeed(TestCsvPath); + + Assert.Equal(0, feed.CurrentIndex); + feed.Next(isNew: true); + Assert.Equal(1, feed.CurrentIndex); + feed.Next(isNew: true); + Assert.Equal(2, feed.CurrentIndex); + } + + [Fact] + public void CurrentIndex_DoesNotIncrementOnUpdate() + { + var feed = new CsvFeed(TestCsvPath); + + feed.Next(isNew: true); + int indexAfterFirst = feed.CurrentIndex; + + feed.Next(isNew: false); + Assert.Equal(indexAfterFirst, feed.CurrentIndex); + } + + [Fact] + public void HasCurrentBar_FalseAtStart() + { + var feed = new CsvFeed(TestCsvPath); + Assert.False(feed.HasCurrentBar); + } + + [Fact] + public void HasCurrentBar_TrueAfterNext() + { + var feed = new CsvFeed(TestCsvPath); + feed.Next(isNew: true); + Assert.True(feed.HasCurrentBar); + } + + [Fact] + public void Data_ReturnsUnderlyingSeries() + { + var feed = new CsvFeed(TestCsvPath); + var data = feed.Data; + + Assert.NotNull(data); + Assert.Equal(feed.Count, data.Count); + } + + #endregion + + #region Next Method Tests + [Fact] public void Next_StreamsDataChronologically() { @@ -109,6 +306,43 @@ public class CsvFeedTests Assert.Equal(lastBar.Time, finalBar.Time); } + [Fact] + public void Next_EmptyData_ReturnsDefaultAndSignalsNoMore() + { + // Create a mock scenario - but since constructor throws on empty, + // we test the behavior when all data is consumed + string tempCsv = CreateTempCsv(new[] + { + "timestamp,open,high,low,close,volume", + "2023-01-01,100,101,99,100,1000" + }); + var feed = new CsvFeed(tempCsv); + + // Consume all data + bool isNew = true; + feed.Next(ref isNew); + + // Now at end + isNew = true; + var bar = feed.Next(ref isNew); + Assert.False(isNew); + Assert.Equal(100.0, bar.Close); // Returns last bar + } + + [Fact] + public void Next_DefaultParameter_IsNewTrue() + { + var feed = new CsvFeed(TestCsvPath); + + var bar1 = feed.Next(); // Default isNew = true + var bar2 = feed.Next(); // Default isNew = true + Assert.True(bar2.Time > bar1.Time); + } + + #endregion + + #region Fetch Method Tests + [Fact] public void Fetch_ReturnsCorrectNumberOfBars() { @@ -124,15 +358,25 @@ public class CsvFeedTests } [Fact] - public void Fetch_InvalidCount_ThrowsArgumentException() + public void Fetch_ZeroCount_ThrowsArgumentException() { var feed = new CsvFeed(TestCsvPath); - var startTime = DateTime.UtcNow.Ticks; var interval = TimeSpan.FromDays(1); - Assert.Throws(() => feed.Fetch(0, startTime, interval)); - Assert.Throws(() => feed.Fetch(-1, startTime, interval)); + var ex = Assert.Throws(() => feed.Fetch(0, startTime, interval)); + Assert.Equal("count", ex.ParamName); + } + + [Fact] + public void Fetch_NegativeCount_ThrowsArgumentException() + { + var feed = new CsvFeed(TestCsvPath); + var startTime = DateTime.UtcNow.Ticks; + var interval = TimeSpan.FromDays(1); + + var ex = Assert.Throws(() => feed.Fetch(-1, startTime, interval)); + Assert.Equal("count", ex.ParamName); } [Fact] @@ -154,6 +398,174 @@ public class CsvFeedTests Assert.True(bar.Time >= startTime); } + [Fact] + public void Fetch_ResetsHasCurrentBar() + { + var feed = new CsvFeed(TestCsvPath); + + feed.Next(isNew: true); + Assert.True(feed.HasCurrentBar); + + var startTime = new DateTime(2025, 7, 1, 0, 0, 0, DateTimeKind.Utc).Ticks; + feed.Fetch(5, startTime, TimeSpan.FromDays(1)); + + Assert.False(feed.HasCurrentBar); + } + + #endregion + + #region Reset Method Tests + + [Fact] + public void Reset_ReturnsToStart() + { + var feed = new CsvFeed(TestCsvPath); + + // Advance several bars + var firstBar = feed.Next(isNew: true); + feed.Next(isNew: true); + feed.Next(isNew: true); + Assert.Equal(3, feed.CurrentIndex); + + // Reset + feed.Reset(); + + Assert.Equal(0, feed.CurrentIndex); + Assert.True(feed.HasMore); + Assert.False(feed.HasCurrentBar); + + // Next bar should be first bar again + var afterReset = feed.Next(isNew: true); + Assert.Equal(firstBar.Time, afterReset.Time); + Assert.Equal(firstBar.Close, afterReset.Close); + } + + [Fact] + public void Reset_WithIndex_SetsCorrectPosition() + { + var feed = new CsvFeed(TestCsvPath); + + // Reset to middle + int targetIndex = 50; + feed.Reset(targetIndex); + + Assert.Equal(targetIndex, feed.CurrentIndex); + Assert.False(feed.HasCurrentBar); + + // Next bar should be at that index + var bar = feed.Next(isNew: true); + var expectedBar = feed.GetBar(targetIndex); + Assert.Equal(expectedBar.Time, bar.Time); + } + + [Fact] + public void Reset_WithNegativeIndex_ThrowsArgumentOutOfRangeException() + { + var feed = new CsvFeed(TestCsvPath); + var ex = Assert.Throws(() => feed.Reset(-1)); + Assert.Equal("index", ex.ParamName); + } + + [Fact] + public void Reset_WithIndexBeyondCount_ThrowsArgumentOutOfRangeException() + { + var feed = new CsvFeed(TestCsvPath); + var ex = Assert.Throws(() => feed.Reset(feed.Count + 1)); + Assert.Equal("index", ex.ParamName); + } + + [Fact] + public void Reset_WithIndexAtCount_IsValid() + { + // Resetting to exactly Count means "at end" - valid but no more data + var feed = new CsvFeed(TestCsvPath); + feed.Reset(feed.Count); + + Assert.Equal(feed.Count, feed.CurrentIndex); + Assert.False(feed.HasMore); + } + + #endregion + + #region GetBar Method Tests + + [Fact] + public void GetBar_ReturnsCorrectBar() + { + var feed = new CsvFeed(TestCsvPath); + + // Get bar without affecting streaming + var bar0 = feed.GetBar(0); + var bar1 = feed.GetBar(1); + + // Streaming position unchanged + Assert.Equal(0, feed.CurrentIndex); + + // Bars should be in chronological order + Assert.True(bar1.Time > bar0.Time); + } + + [Fact] + public void GetBar_NegativeIndex_ThrowsArgumentOutOfRangeException() + { + var feed = new CsvFeed(TestCsvPath); + var ex = Assert.Throws(() => feed.GetBar(-1)); + Assert.Equal("index", ex.ParamName); + } + + [Fact] + public void GetBar_IndexAtCount_ThrowsArgumentOutOfRangeException() + { + var feed = new CsvFeed(TestCsvPath); + var ex = Assert.Throws(() => feed.GetBar(feed.Count)); + Assert.Equal("index", ex.ParamName); + } + + [Fact] + public void GetBar_DoesNotAffectStreaming() + { + var feed = new CsvFeed(TestCsvPath); + + // Stream first bar + var streamed = feed.Next(isNew: true); + int indexAfter = feed.CurrentIndex; + + // Random access + var bar50 = feed.GetBar(50); + Assert.True(bar50.Time > 0); + + // Streaming position unchanged + Assert.Equal(indexAfter, feed.CurrentIndex); + + // Continue streaming + var next = feed.Next(isNew: true); + Assert.True(next.Time > streamed.Time); + } + + [Fact] + public void GetBar_ConsistentWithNext() + { + var feed = new CsvFeed(TestCsvPath); + + // Get bars via random access + var bar0 = feed.GetBar(0); + var bar1 = feed.GetBar(1); + var bar2 = feed.GetBar(2); + + // Get same bars via streaming + var streamed0 = feed.Next(isNew: true); + var streamed1 = feed.Next(isNew: true); + var streamed2 = feed.Next(isNew: true); + + Assert.Equal(bar0.Time, streamed0.Time); + Assert.Equal(bar1.Time, streamed1.Time); + Assert.Equal(bar2.Time, streamed2.Time); + } + + #endregion + + #region OHLCV Validation Tests + [Fact] public void LoadFromCsv_ParsesValuesCorrectly() { @@ -190,10 +602,91 @@ public class CsvFeedTests for (int i = 1; i < bars.Count; i++) { Assert.True(bars[i].Time > bars[i - 1].Time, - $"Bar {i} time ({bars[i].AsDateTime}) should be after bar {i-1} time ({bars[i-1].AsDateTime})"); + $"Bar {i} time ({bars[i].AsDateTime}) should be after bar {i - 1} time ({bars[i - 1].AsDateTime})"); } } + [Fact] + public void LoadFromCsv_AllBarsHaveValidOHLCV() + { + var feed = new CsvFeed(TestCsvPath); + + for (int i = 0; i < feed.Count; i++) + { + var bar = feed.GetBar(i); + + Assert.True(double.IsFinite(bar.Open), $"Bar {i} has non-finite Open"); + Assert.True(double.IsFinite(bar.High), $"Bar {i} has non-finite High"); + Assert.True(double.IsFinite(bar.Low), $"Bar {i} has non-finite Low"); + Assert.True(double.IsFinite(bar.Close), $"Bar {i} has non-finite Close"); + Assert.True(double.IsFinite(bar.Volume), $"Bar {i} has non-finite Volume"); + + Assert.True(bar.High >= bar.Low, $"Bar {i}: High ({bar.High}) < Low ({bar.Low})"); + Assert.True(bar.High >= bar.Open, $"Bar {i}: High ({bar.High}) < Open ({bar.Open})"); + Assert.True(bar.High >= bar.Close, $"Bar {i}: High ({bar.High}) < Close ({bar.Close})"); + Assert.True(bar.Low <= bar.Open, $"Bar {i}: Low ({bar.Low}) > Open ({bar.Open})"); + Assert.True(bar.Low <= bar.Close, $"Bar {i}: Low ({bar.Low}) > Close ({bar.Close})"); + } + } + + [Fact] + public void LoadFromCsv_ParsesDecimalsCorrectly() + { + string tempCsv = CreateTempCsv(new[] + { + "timestamp,open,high,low,close,volume", + "2023-01-01,100.1234,101.5678,99.9999,100.0001,1234567.89" + }); + var feed = new CsvFeed(tempCsv); + var bar = feed.Next(isNew: true); + + Assert.Equal(100.1234, bar.Open, precision: 4); + Assert.Equal(101.5678, bar.High, precision: 4); + Assert.Equal(99.9999, bar.Low, precision: 4); + Assert.Equal(100.0001, bar.Close, precision: 4); + Assert.Equal(1234567.89, bar.Volume, precision: 2); + } + + [Fact] + public void LoadFromCsv_ParsesNegativeValues() + { + // While negative prices are unusual, the parser should handle them + string tempCsv = CreateTempCsv(new[] + { + "timestamp,open,high,low,close,volume", + "2023-01-01,-100,50,-150,-50,1000" + }); + var feed = new CsvFeed(tempCsv); + var bar = feed.Next(isNew: true); + + Assert.Equal(-100, bar.Open); + Assert.Equal(50, bar.High); + Assert.Equal(-150, bar.Low); + Assert.Equal(-50, bar.Close); + } + + [Fact] + public void LoadFromCsv_ParsesScientificNotation() + { + string tempCsv = CreateTempCsv(new[] + { + "timestamp,open,high,low,close,volume", + "2023-01-01,1.5e2,2e2,1e2,1.75e2,1e6" + }); + var feed = new CsvFeed(tempCsv); + var bar = feed.Next(isNew: true); + + Assert.Equal(150, bar.Open); + Assert.Equal(200, bar.High); + Assert.Equal(100, bar.Low); + Assert.Equal(175, bar.Close); + Assert.Equal(1000000, bar.Volume); + } + + #endregion + + #region IFeed Interface Tests + [Fact] public void CsvFeed_WorksWithIFeedInterface() { @@ -206,6 +699,35 @@ public class CsvFeedTests Assert.True(bar2.Time > bar1.Time); } + [Fact] + public void CsvFeed_IFeedRefOverload() + { + IFeed feed = new CsvFeed(TestCsvPath); + + bool isNew = true; + var bar1 = feed.Next(ref isNew); + Assert.True(bar1.Time > 0); + + isNew = false; + var bar1Update = feed.Next(ref isNew); + Assert.Equal(bar1.Time, bar1Update.Time); + } + + [Fact] + public void CsvFeed_IFeedFetch() + { + IFeed feed = new CsvFeed(TestCsvPath); + + var startTime = new DateTime(2025, 7, 1, 0, 0, 0, DateTimeKind.Utc).Ticks; + var series = feed.Fetch(5, startTime, TimeSpan.FromDays(1)); + + Assert.True(series.Count > 0); + } + + #endregion + + #region Edge Case Tests + [Fact] public void Next_MixedNewAndUpdate_WorksCorrectly() { @@ -248,49 +770,160 @@ public class CsvFeedTests var series = feed.Fetch(5, startTime, TimeSpan.FromDays(1)); // Should return empty or minimal data - Assert.True(series.Count == 0); + Assert.Empty(series); } [Fact] public void Fetch_HandlesGapsCorrectly() { - string tempCsv = Path.GetTempFileName() + ".csv"; - try + // Create CSV with gaps using helper + string tempCsv = CreateTempCsv(new[] { - // Create CSV with gaps - // Date, Open, High, Low, Close, Volume - // 2023-01-01 (Sunday) - // 2023-01-02 (Monday) - // 2023-01-04 (Wednesday) - Gap of Tuesday - // 2023-01-05 (Thursday) - var lines = new[] - { - "Date,Open,High,Low,Close,Volume", - "2023-01-05,103,104,102,103,1000", - "2023-01-04,102,103,101,102,1000", - "2023-01-02,101,102,100,101,1000", - "2023-01-01,100,101,99,100,1000" - }; - File.WriteAllLines(tempCsv, lines); + "timestamp,open,high,low,close,volume", + "2023-01-05,103,104,102,103,1000", + "2023-01-04,102,103,101,102,1000", + "2023-01-02,101,102,100,101,1000", + "2023-01-01,100,101,99,100,1000" + }); - var feed = new CsvFeed(tempCsv); - var startTime = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks; - var interval = TimeSpan.FromDays(1); + var feed = new CsvFeed(tempCsv); + var startTime = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks; + var interval = TimeSpan.FromDays(1); - // Fetch 5 bars. Should get 4 bars (Jan 1, 2, 4, 5). - var series = feed.Fetch(10, startTime, interval); + // Fetch bars. Should get 4 bars (Jan 1, 2, 4, 5). + var series = feed.Fetch(10, startTime, interval); - Assert.Equal(4, series.Count); - Assert.Equal(startTime, series[0].Time); // Jan 1 - Assert.Equal(startTime + interval.Ticks, series[1].Time); // Jan 2 - // Gap here - Assert.Equal(startTime + 3 * interval.Ticks, series[2].Time); // Jan 4 - Assert.Equal(startTime + 4 * interval.Ticks, series[3].Time); // Jan 5 + Assert.Equal(4, series.Count); + Assert.Equal(startTime, series[0].Time); // Jan 1 + Assert.Equal(startTime + interval.Ticks, series[1].Time); // Jan 2 + // Gap here (Jan 3 missing) + Assert.Equal(startTime + 3 * interval.Ticks, series[2].Time); // Jan 4 + Assert.Equal(startTime + 4 * interval.Ticks, series[3].Time); // Jan 5 + } + + [Fact] + public void SingleBar_StreamsAndEnds() + { + string tempCsv = CreateTempCsv(new[] + { + "timestamp,open,high,low,close,volume", + "2023-01-01,100,101,99,100,1000" + }); + var feed = new CsvFeed(tempCsv); + + Assert.Equal(1, feed.Count); + Assert.True(feed.HasMore); + + bool isNew = true; + var bar = feed.Next(ref isNew); + Assert.True(isNew); + Assert.Equal(100.0, bar.Close); + Assert.False(feed.HasMore); + + // Try to get next + isNew = true; + var noMore = feed.Next(ref isNew); + Assert.False(isNew); // Signals end + Assert.Equal(bar.Time, noMore.Time); // Returns last bar + } + + [Fact] + public void WhitespaceInValues_Trimmed() + { + string tempCsv = CreateTempCsv(new[] + { + "timestamp,open,high,low,close,volume", + " 2023-01-01 , 100 , 101 , 99 , 100 , 1000 " + }); + var feed = new CsvFeed(tempCsv); + var bar = feed.Next(isNew: true); + + Assert.Equal(100.0, bar.Open); + Assert.Equal(101.0, bar.High); + Assert.Equal(99.0, bar.Low); + Assert.Equal(100.0, bar.Close); + Assert.Equal(1000.0, bar.Volume); + } + + [Fact] + public void ZeroValues_Accepted() + { + string tempCsv = CreateTempCsv(new[] + { + "timestamp,open,high,low,close,volume", + "2023-01-01,0,0,0,0,0" + }); + var feed = new CsvFeed(tempCsv); + var bar = feed.Next(isNew: true); + + Assert.Equal(0.0, bar.Open); + Assert.Equal(0.0, bar.High); + Assert.Equal(0.0, bar.Low); + Assert.Equal(0.0, bar.Close); + Assert.Equal(0.0, bar.Volume); + } + + [Fact] + public void VeryLargeValues_Parsed() + { + string tempCsv = CreateTempCsv(new[] + { + "timestamp,open,high,low,close,volume", + "2023-01-01,999999999.99,1000000000.01,999999999.00,999999999.50,9999999999999" + }); + var feed = new CsvFeed(tempCsv); + var bar = feed.Next(isNew: true); + + Assert.Equal(999999999.99, bar.Open, precision: 2); + Assert.Equal(1000000000.01, bar.High, precision: 2); + Assert.Equal(999999999.00, bar.Low, precision: 2); + Assert.Equal(999999999.50, bar.Close, precision: 2); + Assert.Equal(9999999999999.0, bar.Volume, precision: 0); + } + + [Fact] + public void ConsecutiveResets_WorkCorrectly() + { + var feed = new CsvFeed(TestCsvPath); + + feed.Next(isNew: true); + feed.Next(isNew: true); + feed.Reset(); + feed.Reset(); + feed.Reset(); + + Assert.Equal(0, feed.CurrentIndex); + Assert.False(feed.HasCurrentBar); + } + + [Fact] + public void StreamThenResetThenStream_Consistent() + { + var feed = new CsvFeed(TestCsvPath); + + // First pass + var firstPass = new List(); + for (int i = 0; i < 10; i++) + { + firstPass.Add(feed.Next(isNew: true).Close); } - finally + + // Reset + feed.Reset(); + + // Second pass + var secondPass = new List(); + for (int i = 0; i < 10; i++) { - if (File.Exists(tempCsv)) - File.Delete(tempCsv); + secondPass.Add(feed.Next(isNew: true).Close); + } + + // Should be identical + for (int i = 0; i < 10; i++) + { + Assert.Equal(firstPass[i], secondPass[i]); } } + + #endregion } diff --git a/lib/feeds/csv/CsvFeed.cs b/lib/feeds/csv/CsvFeed.cs index 41a6194a..b53a64fc 100644 --- a/lib/feeds/csv/CsvFeed.cs +++ b/lib/feeds/csv/CsvFeed.cs @@ -1,28 +1,80 @@ using System.Globalization; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; namespace QuanTAlib; +/// +/// Parsed OHLCV data from a CSV line. +/// +[StructLayout(LayoutKind.Auto)] +internal readonly record struct ParsedOhlcv(long Time, double Open, double High, double Low, double Close, double Volume); + +/// +/// Mutable state for parsing OHLCV columns. Used as ref parameter to reduce method signature size. +/// +[StructLayout(LayoutKind.Auto)] +internal ref struct OhlcvParseState +{ + public long Time; + public double Open; + public double High; + public double Low; + public double Close; + public double Volume; +} + /// /// CSV file feed for loading historical OHLCV data. /// Loads data in constructor and streams through it with Next() or returns batches with Fetch(). /// CSV format: timestamp,open,high,low,close,volume (header required) /// Timestamp format: YYYY-MM-DD (UTC midnight assumed) /// -public class CsvFeed : IFeed +[SkipLocalsInit] +public sealed class CsvFeed : IFeed { private readonly TBarSeries _data; + private readonly string _filePath; // Streaming state private int _currentIndex; private TBar _currentBar; private bool _hasCurrentBar; + /// + /// Gets the total number of bars available in the CSV file. + /// + public int Count => _data.Count; + + /// + /// Gets the file path of the loaded CSV. + /// + public string FilePath => _filePath; + + /// + /// Gets whether there are more bars to stream. + /// + public bool HasMore => _currentIndex < _data.Count; + + /// + /// Gets the current streaming position (0-based index). + /// + public int CurrentIndex => _currentIndex; + + /// + /// Gets whether the feed has a current bar in progress. + /// + public bool HasCurrentBar => _hasCurrentBar; + /// /// Loads CSV file and prepares data for streaming. /// Data is reversed to chronological order (oldest first). /// /// Path to CSV file + /// Thrown when filePath is null or empty + /// Thrown when the specified file does not exist + /// Thrown when CSV file is empty or contains only header + /// Thrown when CSV format is invalid public CsvFeed(string filePath) { if (string.IsNullOrWhiteSpace(filePath)) @@ -31,6 +83,7 @@ public class CsvFeed : IFeed if (!File.Exists(filePath)) throw new FileNotFoundException($"CSV file not found: {filePath}", filePath); + _filePath = filePath; _data = LoadFromCsv(filePath); _currentIndex = 0; } @@ -65,37 +118,131 @@ public class CsvFeed : IFeed var series = new TBarSeries(dataLines.Count); + // Pre-allocate arrays for bulk loading (SoA layout) + long[] t = new long[dataLines.Count]; + double[] o = new double[dataLines.Count]; + double[] h = new double[dataLines.Count]; + double[] l = new double[dataLines.Count]; + double[] c = new double[dataLines.Count]; + double[] v = new double[dataLines.Count]; + for (int i = 0; i < dataLines.Count; i++) { var line = dataLines[i]; - - var parts = line.Split(','); int originalLineNumber = dataLines.Count - i + 1; - if (parts.Length != 6) - throw new FormatException($"Invalid CSV format at line {originalLineNumber}. Expected 6 columns, found {parts.Length}"); - // Parse timestamp (YYYY-MM-DD format, assume UTC midnight) - if (!DateTime.TryParseExact(parts[0].Trim(), "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var timestamp)) - { - throw new FormatException($"Failed to parse timestamp at line {originalLineNumber}: {line}"); - } - - // Parse OHLCV values - if (!double.TryParse(parts[1].Trim(), CultureInfo.InvariantCulture, out double open) || - !double.TryParse(parts[2].Trim(), CultureInfo.InvariantCulture, out double high) || - !double.TryParse(parts[3].Trim(), CultureInfo.InvariantCulture, out double low) || - !double.TryParse(parts[4].Trim(), CultureInfo.InvariantCulture, out double close) || - !double.TryParse(parts[5].Trim(), CultureInfo.InvariantCulture, out double volume)) - { - throw new FormatException($"Failed to parse CSV line {originalLineNumber}: {line}"); - } - - series.Add(timestamp, open, high, low, close, volume, isNew: true); + var parsed = ParseCsvLine(line, originalLineNumber); + t[i] = parsed.Time; + o[i] = parsed.Open; + h[i] = parsed.High; + l[i] = parsed.Low; + c[i] = parsed.Close; + v[i] = parsed.Volume; } + // Bulk add to series + series.Add(t, o, h, l, c, v); + return series; } + /// + /// Parses a single CSV line into OHLCV components. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static ParsedOhlcv ParseCsvLine(string line, int lineNumber) + { + // Use Span-based splitting for reduced allocations + ReadOnlySpan lineSpan = line.AsSpan(); + + int col = 0; + int start = 0; + OhlcvParseState state = default; + + for (int i = 0; i < lineSpan.Length; i++) + { + if (lineSpan[i] == ',') + { + var segment = lineSpan[start..i].Trim(); + ParseColumn(segment, col, lineNumber, line, ref state); + col++; + start = i + 1; + } + } + + // Process the last segment after the final comma + if (start <= lineSpan.Length) + { + var segment = lineSpan[start..].Trim(); + ParseColumn(segment, col, lineNumber, line, ref state); + col++; + } + + if (col != 6) + { + throw new FormatException($"Invalid CSV format at line {lineNumber}. Expected 6 columns, found {col}"); + } + + return new ParsedOhlcv(state.Time, state.Open, state.High, state.Low, state.Close, state.Volume); + } + + /// + /// Parses a single column value into the appropriate OHLCV field. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void ParseColumn( + ReadOnlySpan segment, + int col, + int lineNumber, + string line, + ref OhlcvParseState state) + { + switch (col) + { + case 0: // Timestamp + if (!DateTime.TryParseExact(segment, "yyyy-MM-dd", CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var timestamp)) + { + throw new FormatException($"Failed to parse timestamp at line {lineNumber}: {line}"); + } + state.Time = timestamp.Ticks; + break; + case 1: // Open + if (!double.TryParse(segment, NumberStyles.Float, CultureInfo.InvariantCulture, out state.Open)) + { + throw new FormatException($"Failed to parse open price at line {lineNumber}: {line}"); + } + break; + case 2: // High + if (!double.TryParse(segment, NumberStyles.Float, CultureInfo.InvariantCulture, out state.High)) + { + throw new FormatException($"Failed to parse high price at line {lineNumber}: {line}"); + } + break; + case 3: // Low + if (!double.TryParse(segment, NumberStyles.Float, CultureInfo.InvariantCulture, out state.Low)) + { + throw new FormatException($"Failed to parse low price at line {lineNumber}: {line}"); + } + break; + case 4: // Close + if (!double.TryParse(segment, NumberStyles.Float, CultureInfo.InvariantCulture, out state.Close)) + { + throw new FormatException($"Failed to parse close price at line {lineNumber}: {line}"); + } + break; + case 5: // Volume + if (!double.TryParse(segment, NumberStyles.Float, CultureInfo.InvariantCulture, out state.Volume)) + { + throw new FormatException($"Failed to parse volume at line {lineNumber}: {line}"); + } + break; + default: + // Extra columns are ignored - this handles the default case requirement + break; + } + } + /// /// Gets the next bar with full bidirectional control. /// When end of data reached, returns last bar and sets isNew=false. @@ -123,11 +270,8 @@ public class CsvFeed : IFeed _currentIndex++; _hasCurrentBar = true; } - else - { - // Update current bar - CSV has no intra-bar updates, return same bar - // No change to _currentBar or _currentIndex - } + // else: Update current bar - CSV has no intra-bar updates, return same bar + // No change to _currentBar or _currentIndex return _currentBar; } @@ -140,10 +284,16 @@ public class CsvFeed : IFeed { return Next(ref isNew); } + /// /// Returns a filtered subset of data matching the criteria. /// Resets streaming position to start of returned data. /// + /// Number of bars to retrieve (must be positive) + /// Starting timestamp in ticks + /// Time interval between bars + /// A TBarSeries containing the matched bars + /// Thrown when count is not positive public TBarSeries Fetch(int count, long startTime, TimeSpan interval) { if (count <= 0) @@ -151,16 +301,8 @@ public class CsvFeed : IFeed var result = new TBarSeries(count); - // Find starting index - int startIndex = -1; - for (int i = 0; i < _data.Count; i++) - { - if (_data[i].Time >= startTime) - { - startIndex = i; - break; - } - } + // Find starting index using binary search for better performance + int startIndex = FindStartIndex(startTime); if (startIndex == -1) return result; @@ -168,6 +310,7 @@ public class CsvFeed : IFeed // Collect bars matching interval long expectedTime = startTime; int collected = 0; + long tolerance = interval.Ticks / 2; // Allow 50% tolerance for (int i = startIndex; i < _data.Count && collected < count; i++) { @@ -175,7 +318,6 @@ public class CsvFeed : IFeed // Check if bar time matches expected time (within tolerance) long timeDiff = Math.Abs(bar.Time - expectedTime); - long tolerance = interval.Ticks / 2; // Allow 50% tolerance if (timeDiff <= tolerance) { @@ -204,4 +346,82 @@ public class CsvFeed : IFeed return result; } + + /// + /// Finds the starting index for the given start time using binary search. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private int FindStartIndex(long startTime) + { + if (_data.Count == 0) + return -1; + + // If startTime is before first bar, return 0 + if (_data[0].Time >= startTime) + return 0; + + // If startTime is after last bar, return -1 + if (_data[_data.Count - 1].Time < startTime) + return -1; + + // Binary search for the first bar >= startTime + int left = 0; + int right = _data.Count - 1; + + while (left < right) + { + int mid = left + (right - left) / 2; + + if (_data[mid].Time < startTime) + left = mid + 1; + else + right = mid; + } + + return left; + } + + /// + /// Resets the streaming position to the beginning. + /// + public void Reset() + { + _currentIndex = 0; + _hasCurrentBar = false; + _currentBar = default; + } + + /// + /// Resets the streaming position to a specific index. + /// + /// The index to reset to (must be valid) + /// Thrown when index is out of range + public void Reset(int index) + { + if (index < 0 || index > _data.Count) + throw new ArgumentOutOfRangeException(nameof(index), index, $"Index must be between 0 and {_data.Count}"); + + _currentIndex = index; + _hasCurrentBar = false; + _currentBar = default; + } + + /// + /// Gets the bar at the specified index without affecting streaming position. + /// + /// The index of the bar to retrieve + /// The bar at the specified index + /// Thrown when index is out of range + public TBar GetBar(int index) + { + if (index < 0 || index >= _data.Count) + throw new ArgumentOutOfRangeException(nameof(index), index, $"Index must be between 0 and {_data.Count - 1}"); + + return _data[index]; + } + + /// + /// Gets the underlying data series (read-only access). + /// + public TBarSeries Data => _data; } diff --git a/lib/feeds/gbm/Gbm.Tests.cs b/lib/feeds/gbm/Gbm.Tests.cs index 31bc891e..e68186c3 100644 --- a/lib/feeds/gbm/Gbm.Tests.cs +++ b/lib/feeds/gbm/Gbm.Tests.cs @@ -1,12 +1,121 @@ - namespace QuanTAlib.Tests; public class GBMTests { + #region Constructor Tests + + [Fact] + public void Constructor_DefaultParameters_CreatesValidInstance() + { + var gbm = new GBM(); + + Assert.Equal(100.0, gbm.StartPrice); + Assert.Equal(0.05, gbm.Mu); + Assert.Equal(0.2, gbm.Sigma); + Assert.Equal(100.0, gbm.CurrentPrice); + Assert.False(gbm.HasCurrentBar); + } + + [Fact] + public void Constructor_CustomParameters_SetsCorrectly() + { + var gbm = new GBM(startPrice: 50.0, mu: 0.1, sigma: 0.3, seed: 42); + + Assert.Equal(50.0, gbm.StartPrice); + Assert.Equal(0.1, gbm.Mu); + Assert.Equal(0.3, gbm.Sigma); + Assert.Equal(50.0, gbm.CurrentPrice); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-100)] + public void Constructor_InvalidStartPrice_ThrowsArgumentOutOfRangeException(double startPrice) + { + Assert.Throws(() => new GBM(startPrice: startPrice)); + } + + [Fact] + public void Constructor_NaNStartPrice_ThrowsArgumentOutOfRangeException() + { + Assert.Throws(() => new GBM(startPrice: double.NaN)); + } + + [Fact] + public void Constructor_InfinityStartPrice_ThrowsArgumentOutOfRangeException() + { + Assert.Throws(() => new GBM(startPrice: double.PositiveInfinity)); + Assert.Throws(() => new GBM(startPrice: double.NegativeInfinity)); + } + + [Theory] + [InlineData(-0.01)] + [InlineData(-1)] + public void Constructor_NegativeSigma_ThrowsArgumentOutOfRangeException(double sigma) + { + Assert.Throws(() => new GBM(sigma: sigma)); + } + + [Fact] + public void Constructor_NaNSigma_ThrowsArgumentOutOfRangeException() + { + Assert.Throws(() => new GBM(sigma: double.NaN)); + } + + [Fact] + public void Constructor_InfinitySigma_ThrowsArgumentOutOfRangeException() + { + Assert.Throws(() => new GBM(sigma: double.PositiveInfinity)); + } + + [Fact] + public void Constructor_NaNMu_ThrowsArgumentOutOfRangeException() + { + Assert.Throws(() => new GBM(mu: double.NaN)); + } + + [Fact] + public void Constructor_InfinityMu_ThrowsArgumentOutOfRangeException() + { + Assert.Throws(() => new GBM(mu: double.PositiveInfinity)); + Assert.Throws(() => new GBM(mu: double.NegativeInfinity)); + } + + [Fact] + public void Constructor_ZeroTimeframe_ThrowsArgumentOutOfRangeException() + { + Assert.Throws(() => new GBM(defaultTimeframe: TimeSpan.Zero)); + } + + [Fact] + public void Constructor_NegativeTimeframe_ThrowsArgumentOutOfRangeException() + { + Assert.Throws(() => new GBM(defaultTimeframe: TimeSpan.FromMinutes(-1))); + } + + [Fact] + public void Constructor_ZeroSigma_IsValid() + { + var gbm = new GBM(sigma: 0); + Assert.Equal(0, gbm.Sigma); + } + + [Fact] + public void Constructor_NegativeMu_IsValid() + { + var gbm = new GBM(mu: -0.1); + Assert.Equal(-0.1, gbm.Mu); + } + + #endregion + + #region Next Method Tests + [Fact] public void Next_DefaultParameter_GeneratesNewBar() { - var gbm = new GBM(startPrice: 100.0); + var gbm = new GBM(startPrice: 100.0, seed: 42); var bar1 = gbm.Next(); var bar2 = gbm.Next(); @@ -18,7 +127,7 @@ public class GBMTests [Fact] public void Next_IsNewTrue_AdvancesToNewBar() { - var gbm = new GBM(startPrice: 100.0); + var gbm = new GBM(startPrice: 100.0, seed: 42); var bar1 = gbm.Next(isNew: true); var bar2 = gbm.Next(isNew: true); @@ -30,7 +139,7 @@ public class GBMTests [Fact] public void Next_IsNewFalse_UpdatesCurrentBar() { - var gbm = new GBM(startPrice: 100.0); + var gbm = new GBM(startPrice: 100.0, seed: 42); var bar1 = gbm.Next(isNew: true); long initialTime = bar1.Time; @@ -38,16 +147,15 @@ public class GBMTests var bar2 = gbm.Next(isNew: false); Assert.Equal(initialTime, bar2.Time); - // Price likely changed (GBM random walk) - Assert.NotEqual(bar1.Close, bar2.Close); + Assert.Equal(bar1.Open, bar2.Open); + // High/Low/Close/Volume may change } [Fact] public void Next_RefBool_HonorsRequest() { - var gbm = new GBM(startPrice: 100.0); + var gbm = new GBM(startPrice: 100.0, seed: 42); - // GBM always honors isNew - parameter should remain unchanged bool isNew1 = true; var bar1 = gbm.Next(ref isNew1); Assert.True(isNew1, "GBM should honor isNew=true request"); @@ -64,10 +172,58 @@ public class GBMTests Assert.NotEqual(time1, bar3.Time); } + [Fact] + public void Next_FirstCallWithIsNewFalse_GeneratesBar() + { + var gbm = new GBM(startPrice: 100.0, seed: 42); + + // First call with isNew=false should still generate a bar + var bar = gbm.Next(isNew: false); + + Assert.True(bar.Time > 0); + Assert.True(bar.Open > 0); + Assert.True(gbm.HasCurrentBar); + } + + [Fact] + public void Next_MultipleUpdates_AccumulatesVolume() + { + var gbm = new GBM(startPrice: 100.0, seed: 42); + + var bar1 = gbm.Next(isNew: true); + double initialVolume = bar1.Volume; + + var bar2 = gbm.Next(isNew: false); + + Assert.True(bar2.Volume > initialVolume, "Volume should accumulate on intra-bar updates"); + } + + [Fact] + public void Next_IntraBarUpdates_ExpandsHighLow() + { + var gbm = new GBM(startPrice: 100.0, sigma: 0.5, seed: 42); + + var bar1 = gbm.Next(isNew: true); + double initialHigh = bar1.High; + double initialLow = bar1.Low; + + // Multiple updates should potentially expand the range + for (int i = 0; i < 100; i++) + { + var bar = gbm.Next(isNew: false); + Assert.True(bar.High >= initialHigh || bar.Low <= initialLow || i > 50, + "High-Low range should expand or stay same with updates"); + } + } + + #endregion + + #region Fetch Method Tests + [Fact] public void Fetch_GeneratesCorrectCount() { - var gbm = new GBM(startPrice: 100.0); + var gbm = new GBM(startPrice: 100.0, seed: 42); int count = 10; long startTime = DateTime.UtcNow.Ticks; var interval = TimeSpan.FromMinutes(1); @@ -80,13 +236,12 @@ public class GBMTests [Fact] public void Fetch_GeneratesSequentialBars() { - var gbm = new GBM(startPrice: 100.0); + var gbm = new GBM(startPrice: 100.0, seed: 42); long startTime = DateTime.UtcNow.Ticks; var interval = TimeSpan.FromMinutes(1); var series = gbm.Fetch(5, startTime, interval); - // Verify time sequence for (int i = 1; i < series.Count; i++) { Assert.True(series[i].Time > series[i - 1].Time); @@ -96,13 +251,12 @@ public class GBMTests [Fact] public void Fetch_RespectsInterval() { - var gbm = new GBM(startPrice: 100.0); + var gbm = new GBM(startPrice: 100.0, seed: 42); var interval = TimeSpan.FromHours(1); long startTime = DateTime.UtcNow.Ticks; var series = gbm.Fetch(5, startTime, interval); - // Verify interval spacing for (int i = 1; i < series.Count; i++) { long expectedDiff = interval.Ticks; @@ -114,7 +268,7 @@ public class GBMTests [Fact] public void Fetch_StartsAtSpecifiedTime() { - var gbm = new GBM(startPrice: 100.0); + var gbm = new GBM(startPrice: 100.0, seed: 42); var startTime = new DateTime(2024, 1, 1, 9, 30, 0, DateTimeKind.Utc).Ticks; var interval = TimeSpan.FromMinutes(5); @@ -125,13 +279,43 @@ public class GBMTests Assert.Equal(startTime + 2 * interval.Ticks, series[2].Time); } + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-100)] + public void Fetch_InvalidCount_ThrowsArgumentException(int count) + { + var gbm = new GBM(startPrice: 100.0); + long startTime = DateTime.UtcNow.Ticks; + var interval = TimeSpan.FromMinutes(1); + + Assert.Throws(() => gbm.Fetch(count, startTime, interval)); + } + [Fact] - public void Fetch_WithDifferentIntervals_WorksCorrectly() + public void Fetch_ZeroInterval_ThrowsArgumentOutOfRangeException() { var gbm = new GBM(startPrice: 100.0); long startTime = DateTime.UtcNow.Ticks; - // Test different intervals + Assert.Throws(() => gbm.Fetch(10, startTime, TimeSpan.Zero)); + } + + [Fact] + public void Fetch_NegativeInterval_ThrowsArgumentOutOfRangeException() + { + var gbm = new GBM(startPrice: 100.0); + long startTime = DateTime.UtcNow.Ticks; + + Assert.Throws(() => gbm.Fetch(10, startTime, TimeSpan.FromMinutes(-1))); + } + + [Fact] + public void Fetch_WithDifferentIntervals_WorksCorrectly() + { + var gbm = new GBM(startPrice: 100.0, seed: 42); + long startTime = DateTime.UtcNow.Ticks; + var intervals = new[] { TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(5), @@ -142,7 +326,6 @@ public class GBMTests { var series = gbm.Fetch(3, startTime, interval); - // Verify spacing for (int i = 1; i < series.Count; i++) { long expectedDiff = interval.Ticks; @@ -153,44 +336,276 @@ public class GBMTests } [Fact] - public void GeneratesRealisticOHLCV() + public void Fetch_LargeCount_WorksCorrectly() { - var gbm = new GBM(startPrice: 100.0); + var gbm = new GBM(startPrice: 100.0, seed: 42); long startTime = DateTime.UtcNow.Ticks; var interval = TimeSpan.FromMinutes(1); - var series = gbm.Fetch(10, startTime, interval); + + var series = gbm.Fetch(10000, startTime, interval); + + Assert.Equal(10000, series.Count); + Assert.All(Enumerable.Range(0, series.Count), i => + { + Assert.True(series[i].Open > 0); + Assert.True(series[i].High > 0); + Assert.True(series[i].Low > 0); + Assert.True(series[i].Close > 0); + Assert.True(series[i].Volume > 0); + }); + } + + #endregion + + #region OHLCV Validity Tests + + [Fact] + public void GeneratesRealisticOHLCV() + { + var gbm = new GBM(startPrice: 100.0, seed: 42); + long startTime = DateTime.UtcNow.Ticks; + var interval = TimeSpan.FromMinutes(1); + var series = gbm.Fetch(100, startTime, interval); for (int i = 0; i < series.Count; i++) { var bar = series[i]; // High should be >= max(Open, Close) - Assert.True(bar.High >= Math.Max(bar.Open, bar.Close)); + Assert.True(bar.High >= Math.Max(bar.Open, bar.Close), + $"Bar {i}: High ({bar.High}) should be >= max(Open, Close) ({Math.Max(bar.Open, bar.Close)})"); // Low should be <= min(Open, Close) - Assert.True(bar.Low <= Math.Min(bar.Open, bar.Close)); + Assert.True(bar.Low <= Math.Min(bar.Open, bar.Close), + $"Bar {i}: Low ({bar.Low}) should be <= min(Open, Close) ({Math.Min(bar.Open, bar.Close)})"); + + // High should be >= Low + Assert.True(bar.High >= bar.Low, + $"Bar {i}: High ({bar.High}) should be >= Low ({bar.Low})"); // Volume should be positive - Assert.True(bar.Volume > 0); + Assert.True(bar.Volume > 0, $"Bar {i}: Volume should be positive"); - // All prices should be positive - Assert.True(bar.Open > 0); - Assert.True(bar.High > 0); - Assert.True(bar.Low > 0); - Assert.True(bar.Close > 0); + // All prices should be positive and finite + Assert.True(double.IsFinite(bar.Open) && bar.Open > 0, $"Bar {i}: Open should be positive and finite"); + Assert.True(double.IsFinite(bar.High) && bar.High > 0, $"Bar {i}: High should be positive and finite"); + Assert.True(double.IsFinite(bar.Low) && bar.Low > 0, $"Bar {i}: Low should be positive and finite"); + Assert.True(double.IsFinite(bar.Close) && bar.Close > 0, $"Bar {i}: Close should be positive and finite"); } } + [Fact] + public void ConsecutiveCalls_MaintainContinuity() + { + var gbm = new GBM(startPrice: 100.0, seed: 42); + + var previousBar = gbm.Next(); + var currentBar = gbm.Next(); + + // currentBar.Open should equal previousBar.Close (continuity) + Assert.Equal(previousBar.Close, currentBar.Open); + } + + [Fact] + public void Fetch_MaintainsContinuity() + { + var gbm = new GBM(startPrice: 100.0, seed: 42); + long startTime = DateTime.UtcNow.Ticks; + var interval = TimeSpan.FromMinutes(1); + + var series = gbm.Fetch(10, startTime, interval); + + for (int i = 1; i < series.Count; i++) + { + Assert.True(Math.Abs(series[i - 1].Close - series[i].Open) < 1e-10, + $"Bar {i}: Open should equal previous bar's Close for continuity"); + } + } + + #endregion + + #region Reset Tests + + [Fact] + public void Reset_RestoresInitialState() + { + var gbm = new GBM(startPrice: 100.0, seed: 42); + + // Generate some bars + gbm.Next(); + gbm.Next(); + gbm.Next(); + + Assert.NotEqual(100.0, gbm.CurrentPrice); + Assert.True(gbm.HasCurrentBar); + + // Reset + gbm.Reset(); + + Assert.Equal(100.0, gbm.CurrentPrice); + Assert.False(gbm.HasCurrentBar); + } + + [Fact] + public void Reset_WithStartTime_SetsSpecificTime() + { + var gbm = new GBM(startPrice: 100.0, seed: 42); + long specificTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks; + + gbm.Next(); + gbm.Reset(specificTime); + + var bar = gbm.Next(); + + // The bar time should be based on the reset time + Assert.True(bar.Time > specificTime); + Assert.Equal(100.0, bar.Open); // Should start from initial price + } + + #endregion + + #region Seeded Reproducibility Tests + + [Fact] + public void SeededGenerator_ProducesReproducibleResults() + { + var gbm1 = new GBM(startPrice: 100.0, seed: 42); + var gbm2 = new GBM(startPrice: 100.0, seed: 42); + + long startTime = DateTime.UtcNow.Ticks; + var interval = TimeSpan.FromMinutes(1); + + var series1 = gbm1.Fetch(10, startTime, interval); + var series2 = gbm2.Fetch(10, startTime, interval); + + for (int i = 0; i < series1.Count; i++) + { + Assert.Equal(series1[i].Open, series2[i].Open); + Assert.Equal(series1[i].High, series2[i].High); + Assert.Equal(series1[i].Low, series2[i].Low); + Assert.Equal(series1[i].Close, series2[i].Close); + Assert.Equal(series1[i].Volume, series2[i].Volume); + } + } + + [Fact] + public void DifferentSeeds_ProduceDifferentResults() + { + var gbm1 = new GBM(startPrice: 100.0, seed: 42); + var gbm2 = new GBM(startPrice: 100.0, seed: 123); + + long startTime = DateTime.UtcNow.Ticks; + var interval = TimeSpan.FromMinutes(1); + + var series1 = gbm1.Fetch(10, startTime, interval); + var series2 = gbm2.Fetch(10, startTime, interval); + + bool anyDifferent = false; + for (int i = 0; i < series1.Count; i++) + { + if (series1[i].Close != series2[i].Close) + { + anyDifferent = true; + break; + } + } + + Assert.True(anyDifferent, "Different seeds should produce different results"); + } + + [Fact] + public void UnseededGenerator_ProducesVariableResults() + { + var gbm1 = new GBM(startPrice: 100.0); + var gbm2 = new GBM(startPrice: 100.0); + + // Note: This test may occasionally fail due to randomness, but is extremely unlikely + var bar1 = gbm1.Next(); + var bar2 = gbm2.Next(); + + // At least one value should be different + bool anyDifferent = bar1.Close != bar2.Close || + bar1.High != bar2.High || + bar1.Low != bar2.Low || + bar1.Volume != bar2.Volume; + + Assert.True(anyDifferent, "Unseeded generators should produce different results"); + } + + #endregion + + #region Drift and Volatility Tests + + [Fact] + public void DriftAndVolatility_AffectPriceMovement() + { + var gbmLowVol = new GBM(startPrice: 100.0, mu: 0.0, sigma: 0.01, seed: 42); + var gbmHighVol = new GBM(startPrice: 100.0, mu: 0.0, sigma: 0.5, seed: 42); + + long startTime = DateTime.UtcNow.Ticks; + var interval = TimeSpan.FromMinutes(1); + var seriesLow = gbmLowVol.Fetch(100, startTime, interval); + var seriesHigh = gbmHighVol.Fetch(100, startTime, interval); + + // Calculate standard deviation of returns + double[] returnsLow = new double[99]; + double[] returnsHigh = new double[99]; + + for (int i = 1; i < 100; i++) + { + returnsLow[i - 1] = Math.Log(seriesLow[i].Close / seriesLow[i - 1].Close); + returnsHigh[i - 1] = Math.Log(seriesHigh[i].Close / seriesHigh[i - 1].Close); + } + + double stdLow = CalculateStdDev(returnsLow); + double stdHigh = CalculateStdDev(returnsHigh); + + Assert.True(stdHigh > stdLow, "High volatility should produce larger return dispersion"); + } + + [Fact] + public void ZeroVolatility_ProducesConstantPrices() + { + var gbm = new GBM(startPrice: 100.0, mu: 0.0, sigma: 0.0, seed: 42); + + long startTime = DateTime.UtcNow.Ticks; + var interval = TimeSpan.FromMinutes(1); + var series = gbm.Fetch(10, startTime, interval); + + // With zero volatility and zero drift, price should stay constant + for (int i = 0; i < series.Count; i++) + { + Assert.Equal(100.0, series[i].Close, 10); + } + } + + private static double CalculateStdDev(double[] values) + { + double mean = 0; + for (int i = 0; i < values.Length; i++) + mean += values[i]; + mean /= values.Length; + + double sumSquares = 0; + for (int i = 0; i < values.Length; i++) + sumSquares += (values[i] - mean) * (values[i] - mean); + + return Math.Sqrt(sumSquares / values.Length); + } + + #endregion + + #region State Management Tests + [Fact] public void IntraBarUpdates_ModifyCurrentBar() { - var gbm = new GBM(startPrice: 100.0); + var gbm = new GBM(startPrice: 100.0, seed: 42); var bar1 = gbm.Next(isNew: true); long initialTime = bar1.Time; double initialClose = bar1.Close; - // Loop until price changes (random walk might stay same but unlikely) bool changed = false; for (int i = 0; i < 10; i++) { @@ -209,13 +624,11 @@ public class GBMTests [Fact] public void MixedStreamingAndBatch_WorksCorrectly() { - var gbm = new GBM(startPrice: 100.0); + var gbm = new GBM(startPrice: 100.0, seed: 42); - // Start with streaming _ = gbm.Next(); var bar2 = gbm.Next(); - // Batch generation with explicit time long startTime = bar2.Time + TimeSpan.FromMinutes(1).Ticks; var interval = TimeSpan.FromMinutes(1); var series = gbm.Fetch(3, startTime, interval); @@ -223,59 +636,66 @@ public class GBMTests Assert.True(series[0].Time > bar2.Time); Assert.Equal(3, series.Count); - // Continue streaming after batch (uses internal state) var bar3 = gbm.Next(); Assert.True(bar3.Time > series[2].Time); } [Fact] - public void DriftAndVolatility_AffectPriceMovement() + public void Fetch_ResetsStreamingState() { - // High volatility should produce more price variation - var gbmLowVol = new GBM(startPrice: 100.0, mu: 0.0, sigma: 0.01); - var gbmHighVol = new GBM(startPrice: 100.0, mu: 0.0, sigma: 0.5); + var gbm = new GBM(startPrice: 100.0, seed: 42); + // Create a bar with intra-bar updates + gbm.Next(isNew: true); + gbm.Next(isNew: false); + Assert.True(gbm.HasCurrentBar); + + // Fetch should reset streaming state long startTime = DateTime.UtcNow.Ticks; - var interval = TimeSpan.FromMinutes(1); - var seriesLow = gbmLowVol.Fetch(100, startTime, interval); - var seriesHigh = gbmHighVol.Fetch(100, startTime, interval); + gbm.Fetch(5, startTime, TimeSpan.FromMinutes(1)); - // Calculate price ranges - double rangeLow = seriesLow[99].Close - seriesLow[0].Open; - double rangeHigh = seriesHigh[99].Close - seriesHigh[0].Open; - - // High volatility should generally produce larger absolute movements - Assert.True(Math.Abs(rangeHigh) > Math.Abs(rangeLow) * 0.5); + Assert.False(gbm.HasCurrentBar); } + #endregion + + #region IFeed Interface Tests + [Fact] - public void ConsecutiveCalls_MaintainContinuity() + public void ImplementsIFeed() { - var gbm = new GBM(startPrice: 100.0); + IFeed feed = new GBM(startPrice: 100.0, seed: 42); - var previousBar = gbm.Next(); - var currentBar = gbm.Next(); + var bar1 = feed.Next(isNew: true); + Assert.True(bar1.Time > 0); - // currentBar.Open should equal previousBar.Close (continuity) - Assert.Equal(previousBar.Close, currentBar.Open); + var bar2 = feed.Next(isNew: true); + Assert.True(bar2.Time > bar1.Time); + + long startTime = DateTime.UtcNow.Ticks; + var series = feed.Fetch(5, startTime, TimeSpan.FromMinutes(1)); + Assert.Equal(5, series.Count); } + #endregion + + #region Statelessness Tests + [Fact] public void Stateless_NoHistoryStorage() { var gbm = new GBM(startPrice: 100.0); - // Generate multiple bars for (int i = 0; i < 100; i++) { _ = gbm.Next(); } - // GBM should not expose any history storage - // Use typeof() instead of GetType() to satisfy trimming analyzer var type = typeof(GBM); var barsProperty = type.GetProperty("Bars"); Assert.Null(barsProperty); } + + #endregion } diff --git a/lib/feeds/gbm/ValidationHelper.cs b/lib/feeds/gbm/ValidationHelper.cs index c7b38147..ad44a007 100644 --- a/lib/feeds/gbm/ValidationHelper.cs +++ b/lib/feeds/gbm/ValidationHelper.cs @@ -1,19 +1,75 @@ using System; using System.Collections.Generic; +using System.Runtime.CompilerServices; using Xunit; namespace QuanTAlib.Tests; +/// +/// Provides validation utilities for comparing indicator results against external libraries. +/// Contains tolerance constants and verification methods for cross-library validation. +/// public static class ValidationHelper { + /// + /// Default tolerance for floating-point comparisons (1e-7). + /// Suitable for most indicator comparisons. + /// public const double DefaultTolerance = 1e-7; + + /// + /// Tolerance for Ooples Finance library comparisons (1e-7). + /// May need adjustment for specific indicators with different internal precision. + /// public const double OoplesTolerance = 1e-7; + + /// + /// Tolerance for Skender.Stock.Indicators library comparisons (1e-7). + /// Skender uses decimal internally, so some precision loss is expected. + /// public const double SkenderTolerance = 1e-7; + + /// + /// Tolerance for TA-Lib (TALib.NETCore) library comparisons (1e-7). + /// TA-Lib uses double precision throughout. + /// public const double TalibTolerance = 1e-7; + + /// + /// Tolerance for Tulip library comparisons (1e-7). + /// Note: Tulip may have 1-bar shifts due to different initialization strategies. + /// public const double TulipTolerance = 1e-7; + + /// + /// Relative tolerance for percentage-based comparisons (0.5%). + /// Use when absolute tolerance is not appropriate. + /// public const double RelativeTolerance = 0.005; - public static void VerifyData(TSeries qSeries, IReadOnlyList sSeries, Func selector, int skip = 100, double tolerance = DefaultTolerance) + /// + /// Default number of bars to verify from the end of the series. + /// Using 100 bars ensures we're comparing converged values. + /// + public const int DefaultVerificationCount = 100; + + /// + /// Verifies TSeries results against an external library's results. + /// Compares the last 'skip' values by default. + /// + /// The type of results from the external library + /// QuanTAlib TSeries results + /// External library results + /// Function to extract the comparable value from external results + /// Number of values to verify from the end (default: 100) + /// Tolerance for floating-point comparison + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void VerifyData( + TSeries qSeries, + IReadOnlyList sSeries, + Func selector, + int skip = DefaultVerificationCount, + double tolerance = DefaultTolerance) { Assert.Equal(qSeries.Count, sSeries.Count); @@ -27,11 +83,22 @@ public static class ValidationHelper if (!sValue.HasValue) continue; - Assert.Equal(sValue.Value, qValue, tolerance); + Assert.True( + Math.Abs(qValue - sValue.Value) <= tolerance, + $"Mismatch at index {i}: QuanTAlib={qValue:G17}, External={sValue.Value:G17}, Diff={Math.Abs(qValue - sValue.Value):G17}"); } } - public static void VerifyData(IReadOnlyList qResults, IReadOnlyList sSeries, Func selector, int skip = 100, double tolerance = DefaultTolerance) + /// + /// Verifies IReadOnlyList results against an external library's results. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void VerifyData( + IReadOnlyList qResults, + IReadOnlyList sSeries, + Func selector, + int skip = DefaultVerificationCount, + double tolerance = DefaultTolerance) { Assert.Equal(qResults.Count, sSeries.Count); @@ -45,11 +112,22 @@ public static class ValidationHelper if (!sValue.HasValue) continue; - Assert.Equal(sValue.Value, qValue, tolerance); + Assert.True( + Math.Abs(qValue - sValue.Value) <= tolerance, + $"Mismatch at index {i}: QuanTAlib={qValue:G17}, External={sValue.Value:G17}, Diff={Math.Abs(qValue - sValue.Value):G17}"); } } - public static void VerifyData(double[] qOutput, IReadOnlyList sSeries, Func selector, int skip = 100, double tolerance = DefaultTolerance) + /// + /// Verifies double array results against an external library's results. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void VerifyData( + double[] qOutput, + IReadOnlyList sSeries, + Func selector, + int skip = DefaultVerificationCount, + double tolerance = DefaultTolerance) { Assert.Equal(qOutput.Length, sSeries.Count); @@ -63,11 +141,27 @@ public static class ValidationHelper if (!sValue.HasValue) continue; - Assert.Equal(sValue.Value, qValue, tolerance); + Assert.True( + Math.Abs(qValue - sValue.Value) <= tolerance, + $"Mismatch at index {i}: QuanTAlib={qValue:G17}, External={sValue.Value:G17}, Diff={Math.Abs(qValue - sValue.Value):G17}"); } } - public static void VerifyData(TSeries qSeries, double[] tOutput, int lookback, int skip = 100, double tolerance = DefaultTolerance) + /// + /// Verifies TSeries results against TA-Lib style output with lookback offset. + /// + /// QuanTAlib TSeries results + /// TA-Lib output array + /// TA-Lib lookback period (output is shifted by this amount) + /// Number of values to verify from the end + /// Tolerance for floating-point comparison + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void VerifyData( + TSeries qSeries, + double[] tOutput, + int lookback, + int skip = DefaultVerificationCount, + double tolerance = DefaultTolerance) { int count = qSeries.Count; int start = Math.Max(0, count - skip); @@ -83,11 +177,22 @@ public static class ValidationHelper double tValue = tOutput[tIndex]; - Assert.Equal(tValue, qValue, tolerance); + Assert.True( + Math.Abs(qValue - tValue) <= tolerance, + $"Mismatch at index {i} (TA-Lib index {tIndex}): QuanTAlib={qValue:G17}, TA-Lib={tValue:G17}, Diff={Math.Abs(qValue - tValue):G17}"); } } - public static void VerifyData(IReadOnlyList qResults, double[] tOutput, int lookback, int skip = 100, double tolerance = DefaultTolerance) + /// + /// Verifies IReadOnlyList results against TA-Lib style output with lookback offset. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void VerifyData( + IReadOnlyList qResults, + double[] tOutput, + int lookback, + int skip = DefaultVerificationCount, + double tolerance = DefaultTolerance) { int count = qResults.Count; int start = Math.Max(0, count - skip); @@ -103,11 +208,22 @@ public static class ValidationHelper double tValue = tOutput[tIndex]; - Assert.Equal(tValue, qValue, tolerance); + Assert.True( + Math.Abs(qValue - tValue) <= tolerance, + $"Mismatch at index {i} (TA-Lib index {tIndex}): QuanTAlib={qValue:G17}, TA-Lib={tValue:G17}, Diff={Math.Abs(qValue - tValue):G17}"); } } - public static void VerifyData(double[] qOutput, double[] tOutput, int lookback, int skip = 100, double tolerance = DefaultTolerance) + /// + /// Verifies double array results against TA-Lib style output with lookback offset. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void VerifyData( + double[] qOutput, + double[] tOutput, + int lookback, + int skip = DefaultVerificationCount, + double tolerance = DefaultTolerance) { int count = qOutput.Length; int start = Math.Max(0, count - skip); @@ -123,11 +239,23 @@ public static class ValidationHelper double tValue = tOutput[tIndex]; - Assert.Equal(tValue, qValue, tolerance); + Assert.True( + Math.Abs(qValue - tValue) <= tolerance, + $"Mismatch at index {i} (TA-Lib index {tIndex}): QuanTAlib={qValue:G17}, TA-Lib={tValue:G17}, Diff={Math.Abs(qValue - tValue):G17}"); } } - public static void VerifyData(TSeries qSeries, double[] tOutput, Range outRange, int lookback, int skip = 100, double tolerance = DefaultTolerance) + /// + /// Verifies TSeries results against TA-Lib style output with range and lookback. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void VerifyData( + TSeries qSeries, + double[] tOutput, + Range outRange, + int lookback, + int skip = DefaultVerificationCount, + double tolerance = DefaultTolerance) { int count = qSeries.Count; int start = Math.Max(0, count - skip); @@ -144,11 +272,23 @@ public static class ValidationHelper double tValue = tOutput[tIndex]; - Assert.Equal(tValue, qValue, tolerance); + Assert.True( + Math.Abs(qValue - tValue) <= tolerance, + $"Mismatch at index {i} (TA-Lib index {tIndex}): QuanTAlib={qValue:G17}, TA-Lib={tValue:G17}, Diff={Math.Abs(qValue - tValue):G17}"); } } - public static void VerifyData(IReadOnlyList qResults, double[] tOutput, Range outRange, int lookback, int skip = 100, double tolerance = DefaultTolerance) + /// + /// Verifies IReadOnlyList results against TA-Lib style output with range and lookback. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void VerifyData( + IReadOnlyList qResults, + double[] tOutput, + Range outRange, + int lookback, + int skip = DefaultVerificationCount, + double tolerance = DefaultTolerance) { int count = qResults.Count; int start = Math.Max(0, count - skip); @@ -165,11 +305,23 @@ public static class ValidationHelper double tValue = tOutput[tIndex]; - Assert.Equal(tValue, qValue, tolerance); + Assert.True( + Math.Abs(qValue - tValue) <= tolerance, + $"Mismatch at index {i} (TA-Lib index {tIndex}): QuanTAlib={qValue:G17}, TA-Lib={tValue:G17}, Diff={Math.Abs(qValue - tValue):G17}"); } } - public static void VerifyData(double[] qOutput, double[] tOutput, Range outRange, int lookback, int skip = 100, double tolerance = DefaultTolerance) + /// + /// Verifies double array results against TA-Lib style output with range and lookback. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void VerifyData( + double[] qOutput, + double[] tOutput, + Range outRange, + int lookback, + int skip = DefaultVerificationCount, + double tolerance = DefaultTolerance) { int count = qOutput.Length; int start = Math.Max(0, count - skip); @@ -186,7 +338,111 @@ public static class ValidationHelper double tValue = tOutput[tIndex]; - Assert.Equal(tValue, qValue, tolerance); + Assert.True( + Math.Abs(qValue - tValue) <= tolerance, + $"Mismatch at index {i} (TA-Lib index {tIndex}): QuanTAlib={qValue:G17}, TA-Lib={tValue:G17}, Diff={Math.Abs(qValue - tValue):G17}"); } } + + /// + /// Verifies that all values in the series are finite (not NaN or Infinity). + /// + /// The series to verify + /// Starting index for verification (default: 0) + public static void VerifyAllFinite(TSeries series, int startIndex = 0) + { + for (int i = startIndex; i < series.Count; i++) + { + Assert.True( + double.IsFinite(series[i].Value), + $"Non-finite value at index {i}: {series[i].Value}"); + } + } + + /// + /// Verifies that all values in the array are finite (not NaN or Infinity). + /// + /// The array to verify + /// Starting index for verification (default: 0) + public static void VerifyAllFinite(double[] values, int startIndex = 0) + { + for (int i = startIndex; i < values.Length; i++) + { + Assert.True( + double.IsFinite(values[i]), + $"Non-finite value at index {i}: {values[i]}"); + } + } + + /// + /// Verifies that two series produce the same results (for consistency testing). + /// + /// First series + /// Second series + /// Tolerance for floating-point comparison + public static void VerifySeriesEqual(TSeries series1, TSeries series2, double tolerance = DefaultTolerance) + { + Assert.Equal(series1.Count, series2.Count); + + for (int i = 0; i < series1.Count; i++) + { + Assert.True( + Math.Abs(series1[i].Value - series2[i].Value) <= tolerance, + $"Mismatch at index {i}: Series1={series1[i].Value:G17}, Series2={series2[i].Value:G17}"); + } + } + + /// + /// Calculates the maximum absolute difference between two series. + /// Useful for debugging tolerance issues. + /// + public static double MaxAbsoluteDifference( + TSeries qSeries, + IReadOnlyList sSeries, + Func selector) + { + if (qSeries.Count != sSeries.Count) + throw new ArgumentException("Series must have the same count", nameof(sSeries)); + + double maxDiff = 0; + + for (int i = 0; i < qSeries.Count; i++) + { + double? sValue = selector(sSeries[i]); + if (!sValue.HasValue) continue; + + double diff = Math.Abs(qSeries[i].Value - sValue.Value); + if (diff > maxDiff) + maxDiff = diff; + } + + return maxDiff; + } + + /// + /// Calculates the maximum relative difference between two series. + /// Useful for percentage-based tolerance testing. + /// + public static double MaxRelativeDifference( + TSeries qSeries, + IReadOnlyList sSeries, + Func selector) + { + if (qSeries.Count != sSeries.Count) + throw new ArgumentException("Series must have the same count", nameof(sSeries)); + + double maxDiff = 0; + + for (int i = 0; i < qSeries.Count; i++) + { + double? sValue = selector(sSeries[i]); + if (!sValue.HasValue || sValue.Value == 0) continue; + + double relDiff = Math.Abs((qSeries[i].Value - sValue.Value) / sValue.Value); + if (relDiff > maxDiff) + maxDiff = relDiff; + } + + return maxDiff; + } } diff --git a/lib/feeds/gbm/ValidationTestData.cs b/lib/feeds/gbm/ValidationTestData.cs index 13e8ad28..4433a0d3 100644 --- a/lib/feeds/gbm/ValidationTestData.cs +++ b/lib/feeds/gbm/ValidationTestData.cs @@ -1,42 +1,219 @@ using System; using System.Collections.Generic; -using System.Linq; using Skender.Stock.Indicators; namespace QuanTAlib.Tests; +/// +/// Provides standardized test data for validation tests. +/// Uses GBM (Geometric Brownian Motion) to generate realistic price data +/// and converts it to formats required by external validation libraries. +/// public sealed class ValidationTestData : IDisposable { + /// + /// Default number of bars for validation tests. + /// 5000 bars ensures sufficient convergence for most indicators. + /// + public const int DefaultCount = 5000; + + /// + /// Default starting price for generated data. + /// + public const double DefaultStartPrice = 1000.0; + + /// + /// Default annual drift for GBM (5%). + /// + public const double DefaultMu = 0.05; + + /// + /// Default annual volatility for GBM (200%). + /// High volatility ensures diverse price scenarios. + /// + public const double DefaultSigma = 2.0; + + /// + /// Default random seed for reproducibility. + /// + public const int DefaultSeed = 123; + + /// + /// Gets the generated bar series. + /// public TBarSeries Bars { get; } + + /// + /// Gets the close price series. + /// public TSeries Data { get; } + + /// + /// Gets the quotes in Skender.Stock.Indicators format. + /// public IReadOnlyList SkenderQuotes { get; } + + /// + /// Gets the raw close price data as a ReadOnlyMemory for span-based APIs. + /// public ReadOnlyMemory RawData { get; } - public ValidationTestData(int count = 5000, double startPrice = 1000.0, double mu = 0.05, double sigma = 2.0, int seed = 123) + /// + /// Gets the raw open prices as read-only memory. + /// + public ReadOnlyMemory OpenPrices { get; } + + /// + /// Gets the raw high prices as read-only memory. + /// + public ReadOnlyMemory HighPrices { get; } + + /// + /// Gets the raw low prices as read-only memory. + /// + public ReadOnlyMemory LowPrices { get; } + + /// + /// Gets the raw close prices as read-only memory. + /// + public ReadOnlyMemory ClosePrices { get; } + + /// + /// Gets the raw volume data as read-only memory. + /// + public ReadOnlyMemory VolumeData { get; } + + /// + /// Gets the timestamps as read-only memory. + /// + public ReadOnlyMemory Timestamps { get; } + + /// + /// Gets the number of bars in the dataset. + /// + public int Count => Bars.Count; + + /// + /// Creates validation test data with default parameters. + /// + public ValidationTestData() + : this(DefaultCount, DefaultStartPrice, DefaultMu, DefaultSigma, DefaultSeed) + { + } + + /// + /// Creates validation test data with specified parameters. + /// + /// Number of bars to generate + /// Starting price + /// Annual drift rate + /// Annual volatility + /// Random seed for reproducibility + public ValidationTestData( + int count, + double startPrice = DefaultStartPrice, + double mu = DefaultMu, + double sigma = DefaultSigma, + int seed = DefaultSeed) { var gbm = new GBM(startPrice, mu, sigma, seed: seed); Bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); Data = Bars.Close; - RawData = Data.Select(x => x.Value).ToArray(); - var quotes = new List(); - for (int i = 0; i < Bars.Count; i++) + // Extract raw arrays efficiently (avoid LINQ in hot path) + int barCount = Bars.Count; + var openPrices = new double[barCount]; + var highPrices = new double[barCount]; + var lowPrices = new double[barCount]; + var closePrices = new double[barCount]; + var volumeData = new double[barCount]; + var timestamps = new long[barCount]; + + // Use span-based access for efficiency + var openSpan = Bars.OpenValues; + var highSpan = Bars.HighValues; + var lowSpan = Bars.LowValues; + var closeSpan = Bars.CloseValues; + var volumeSpan = Bars.VolumeValues; + var timeSpan = Bars.Times; + + openSpan.CopyTo(openPrices); + highSpan.CopyTo(highPrices); + lowSpan.CopyTo(lowPrices); + closeSpan.CopyTo(closePrices); + volumeSpan.CopyTo(volumeData); + timeSpan.CopyTo(timestamps); + + // Expose as ReadOnlyMemory to prevent external modification + OpenPrices = openPrices; + HighPrices = highPrices; + LowPrices = lowPrices; + ClosePrices = closePrices; + VolumeData = volumeData; + Timestamps = timestamps; + RawData = closePrices; + + // Build Skender quotes without LINQ + var quotes = new Quote[barCount]; + for (int i = 0; i < barCount; i++) { - quotes.Add(new Quote + quotes[i] = new Quote { - Date = new DateTime(Bars.Open.Times[i], DateTimeKind.Utc), - Open = (decimal)Bars.Open[i].Value, - High = (decimal)Bars.High[i].Value, - Low = (decimal)Bars.Low[i].Value, - Close = (decimal)Bars.Close[i].Value, - Volume = (decimal)Bars.Volume[i].Value - }); + Date = new DateTime(timestamps[i], DateTimeKind.Utc), + Open = (decimal)openPrices[i], + High = (decimal)highPrices[i], + Low = (decimal)lowPrices[i], + Close = (decimal)closePrices[i], + Volume = (decimal)volumeData[i], + }; } SkenderQuotes = quotes; } + /// + /// Creates a subset of the data for smaller tests. + /// + /// Number of bars to include + /// A new ValidationTestData instance with the subset + public ValidationTestData CreateSubset(int count) + { + if (count <= 0 || count > Count) + throw new ArgumentOutOfRangeException(nameof(count), count, $"Count must be between 1 and {Count}"); + + return new ValidationTestData(count, DefaultStartPrice, DefaultMu, DefaultSigma, DefaultSeed); + } + + /// + /// Gets the close price span for SIMD operations. + /// + public ReadOnlySpan GetCloseSpan() => ClosePrices.Span; + + /// + /// Gets the high price span for SIMD operations. + /// + public ReadOnlySpan GetHighSpan() => HighPrices.Span; + + /// + /// Gets the low price span for SIMD operations. + /// + public ReadOnlySpan GetLowSpan() => LowPrices.Span; + + /// + /// Gets the open price span for SIMD operations. + /// + public ReadOnlySpan GetOpenSpan() => OpenPrices.Span; + + /// + /// Gets the volume span for SIMD operations. + /// + public ReadOnlySpan GetVolumeSpan() => VolumeData.Span; + + /// + /// Disposes of resources (no-op, but implements pattern for test fixtures). + /// public void Dispose() { - // No resources to dispose + // No unmanaged resources to dispose + // Implemented for IDisposable pattern compatibility with test fixtures } } diff --git a/lib/feeds/gbm/gbm.cs b/lib/feeds/gbm/gbm.cs index 3abe1033..ff85c2aa 100644 --- a/lib/feeds/gbm/gbm.cs +++ b/lib/feeds/gbm/gbm.cs @@ -11,10 +11,11 @@ namespace QuanTAlib; [SkipLocalsInit] #pragma warning disable S101 // Rename class 'GBM' to match pascal case naming rules #pragma warning disable S2245 // Random is acceptable for simulation/testing purposes -public class GBM : IFeed +public sealed class GBM : IFeed #pragma warning restore S101 { private readonly Random? _rnd; + private readonly double _startPrice; private double _lastPrice; private long _lastTime; @@ -35,14 +36,43 @@ public class GBM : IFeed private double _cachedZ; private bool _hasCachedZ; + /// + /// Gets the annual drift/return rate. + /// + public double Mu => _mu; + + /// + /// Gets the annual volatility. + /// + public double Sigma => _sigma; + + /// + /// Gets the starting price. + /// + public double StartPrice => _startPrice; + + /// + /// Gets the current price state. + /// + public double CurrentPrice => _lastPrice; + + /// + /// Gets whether the generator has a current bar in progress. + /// + public bool HasCurrentBar => _hasCurrentBar; + /// /// Creates a new GBM generator. /// - /// Initial price (default: 100.0, must be positive) - /// Annual drift/return rate (default: 0.05 = 5%) - /// Annual volatility (default: 0.2 = 20%, must be non-negative) - /// Default timeframe for bars (default: 1 minute) + /// Initial price (default: 100.0, must be positive and finite) + /// Annual drift/return rate (default: 0.05 = 5%, must be finite) + /// Annual volatility (default: 0.2 = 20%, must be non-negative and finite) + /// Default timeframe for bars (default: 1 minute, must be positive) /// Optional random seed for reproducibility (default: null for non-deterministic) + /// + /// Thrown when startPrice is not positive/finite, sigma is negative/non-finite, + /// mu is non-finite, or defaultTimeframe is non-positive. + /// public GBM( double startPrice = 100.0, double mu = 0.05, @@ -50,18 +80,33 @@ public class GBM : IFeed TimeSpan? defaultTimeframe = null, int? seed = null) { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(startPrice); - ArgumentOutOfRangeException.ThrowIfNegative(sigma); + // Validate startPrice + if (startPrice <= 0 || !double.IsFinite(startPrice)) + throw new ArgumentOutOfRangeException(nameof(startPrice), startPrice, "Start price must be positive and finite"); + + // Validate mu + if (!double.IsFinite(mu)) + throw new ArgumentOutOfRangeException(nameof(mu), mu, "Drift (mu) must be finite"); + + // Validate sigma + if (sigma < 0 || !double.IsFinite(sigma)) + throw new ArgumentOutOfRangeException(nameof(sigma), sigma, "Volatility (sigma) must be non-negative and finite"); + + // Use provided timeframe or default to 1 minute + var timeframe = defaultTimeframe ?? TimeSpan.FromMinutes(1); + + // Validate timeframe + if (timeframe <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(defaultTimeframe), defaultTimeframe, "Timeframe must be positive"); _rnd = seed.HasValue ? new Random(seed.Value) : null; + _startPrice = startPrice; _lastPrice = startPrice; _lastTime = DateTime.UtcNow.Ticks; _mu = mu; _sigma = sigma; - // Use provided timeframe or default to 1 minute - var timeframe = defaultTimeframe ?? TimeSpan.FromMinutes(1); _defaultTimeStep = timeframe.Ticks; // Calculate dt based on timeframe (assuming 252 trading days/year, 6.5 hours/day) @@ -72,6 +117,33 @@ public class GBM : IFeed _vol = sigma * Math.Sqrt(dt); } + /// + /// Resets the generator to its initial state. + /// + public void Reset() + { + _lastPrice = _startPrice; + _lastTime = DateTime.UtcNow.Ticks; + _currentBar = default; + _hasCurrentBar = false; + _cachedZ = 0; + _hasCachedZ = false; + } + + /// + /// Resets the generator to its initial state with a specific start time. + /// + /// The start time in ticks. + public void Reset(long startTime) + { + _lastPrice = _startPrice; + _lastTime = startTime; + _currentBar = default; + _hasCurrentBar = false; + _cachedZ = 0; + _hasCachedZ = false; + } + /// /// Generates a random double in [0, 1) using either the seeded Random or RandomNumberGenerator. /// @@ -103,6 +175,11 @@ public class GBM : IFeed double u1 = 1.0 - NextDouble(); double u2 = 1.0 - NextDouble(); + + // Guard against log(0) which produces -Infinity + if (u1 <= double.Epsilon) + u1 = double.Epsilon; + double mag = Math.Sqrt(-2.0 * Math.Log(u1)); double angle = 2.0 * Math.PI * u2; @@ -128,17 +205,26 @@ public class GBM : IFeed double z = NextNormal(); double price = _lastPrice * Math.Exp(_drift + _vol * z); + + // Ensure price stays positive and finite + if (!double.IsFinite(price) || price <= 0) + price = _lastPrice; + double volume = 1000 + NextDouble() * 1000; double open = _lastPrice; double close = price; - double high = Math.Max(open, close) * (1.0 + Math.Abs(NextDouble()) * 0.01); - double low = Math.Min(open, close) * (1.0 - Math.Abs(NextDouble()) * 0.01); - // Ensure valid OHLC + double rnd1 = NextDouble(); + double rnd2 = NextDouble(); + + double high = Math.Max(open, close) * (1.0 + rnd1 * 0.01); + double low = Math.Min(open, close) * (1.0 - rnd2 * 0.01); + + // Ensure valid OHLC constraints high = Math.Max(high, Math.Max(open, close)); low = Math.Min(low, Math.Min(open, close)); - low = Math.Max(0.0, low); + low = Math.Max(double.Epsilon, low); // Ensure positive _currentBar = new TBar(currentTime, open, high, low, close, volume); _hasCurrentBar = true; @@ -151,12 +237,18 @@ public class GBM : IFeed // Update current bar (intra-bar tick) double z = NextNormal(); double price = _lastPrice * Math.Exp(_drift + _vol * z); + + // Ensure price stays positive and finite + if (!double.IsFinite(price) || price <= 0) + price = _lastPrice; + double additionalVolume = 1000 + NextDouble() * 1000; var bar = _currentBar; double newClose = price; double newHigh = Math.Max(bar.High, newClose); double newLow = Math.Min(bar.Low, newClose); + newLow = Math.Max(double.Epsilon, newLow); // Ensure positive double newVolume = bar.Volume + additionalVolume; _currentBar = new TBar(bar.Time, bar.Open, newHigh, newLow, newClose, newVolume); @@ -179,13 +271,19 @@ public class GBM : IFeed /// /// Generates a batch of bars using optimized batch processing with explicit time parameters. /// + /// Number of bars to generate (must be positive) + /// Starting timestamp in ticks + /// Time interval between bars (must be positive) + /// A TBarSeries containing the generated bars + /// Thrown when count is not positive + /// Thrown when interval is not positive [MethodImpl(MethodImplOptions.AggressiveInlining)] public TBarSeries Fetch(int count, long startTime, TimeSpan interval) { if (count <= 0) throw new ArgumentException("Count must be positive", nameof(count)); if (interval <= TimeSpan.Zero) - throw new ArgumentOutOfRangeException(nameof(interval), "Interval must be positive"); + throw new ArgumentOutOfRangeException(nameof(interval), interval, "Interval must be positive"); var series = new TBarSeries(count); @@ -212,6 +310,10 @@ public class GBM : IFeed double z = NextNormal(); double price = currentPrice * Math.Exp(drift + vol * z); + // Ensure price stays positive and finite + if (!double.IsFinite(price) || price <= 0) + price = currentPrice; + double open = currentPrice; double close = price; @@ -223,13 +325,13 @@ public class GBM : IFeed o[i] = open; c[i] = close; - double high = Math.Max(open, close) * (1.0 + Math.Abs(rnd1) * 0.01); - double low = Math.Min(open, close) * (1.0 - Math.Abs(rnd2) * 0.01); + double high = Math.Max(open, close) * (1.0 + rnd1 * 0.01); + double low = Math.Min(open, close) * (1.0 - rnd2 * 0.01); - // Ensure valid OHLC + // Ensure valid OHLC constraints high = Math.Max(high, Math.Max(open, close)); low = Math.Min(low, Math.Min(open, close)); - low = Math.Max(0.0, low); + low = Math.Max(double.Epsilon, low); // Ensure positive h[i] = high; l[i] = low; @@ -252,3 +354,4 @@ public class GBM : IFeed return series; } } +#pragma warning restore S2245 diff --git a/lib/momentum/adx/Adx.Tests.cs b/lib/momentum/adx/Adx.Tests.cs index 111c6878..ef387b69 100644 --- a/lib/momentum/adx/Adx.Tests.cs +++ b/lib/momentum/adx/Adx.Tests.cs @@ -54,6 +54,28 @@ public class AdxTests Assert.Equal(adx2.DiMinus.Value, adx.DiMinus.Value, 1e-9); } + [Fact] + public void IterativeCorrections_RestoreToOriginalState() + { + 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 < 50; i++) + adx.Update(bars[i]); + + var originalValue = adx.Last; + + for (int m = 0; m < 5; m++) + { + var modified = new TBar(bars[49].Time, bars[49].Open, bars[49].High + m, bars[49].Low - m, bars[49].Close, bars[49].Volume); + adx.Update(modified, isNew: false); + } + + var restored = adx.Update(bars[49], isNew: false); + Assert.Equal(originalValue.Value, restored.Value, 9); + } + [Fact] public void Reset_Works() { @@ -79,6 +101,75 @@ public class AdxTests Assert.True(double.IsFinite(adx.Last.Value)); } + [Fact] + public void IsHot_BecomesTrueWhenBufferFull() + { + var adx = new Adx(14); + var gbm = new GBM(); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + Assert.False(adx.IsHot); + + for (int i = 0; i < bars.Count; i++) + { + adx.Update(bars[i]); + if (adx.IsHot) break; + } + + Assert.True(adx.IsHot); + } + + [Fact] + public void NaN_Input_UsesLastValidValue() + { + var adx = new Adx(14); + var gbm = new GBM(); + var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 40; i++) + adx.Update(bars[i]); + + var nanBar = new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, 100); + var result = adx.Update(nanBar); + + Assert.True(double.IsFinite(result.Value)); + } + + [Fact] + public void Infinity_Input_UsesLastValidValue() + { + var adx = new Adx(14); + var gbm = new GBM(); + var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 40; i++) + adx.Update(bars[i]); + + var infBar = new TBar(DateTime.UtcNow, double.PositiveInfinity, double.PositiveInfinity, 0, 100, 100); + var result = adx.Update(infBar); + + Assert.True(double.IsFinite(result.Value)); + } + + [Fact] + public void AllModes_ProduceSameResult() + { + var gbm = new GBM(seed: 123); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // 1. Batch Mode + var batchResult = Adx.Batch(bars, 14); + double expected = batchResult.Last.Value; + + // 2. Streaming Mode + var streamAdx = new Adx(14); + for (int i = 0; i < bars.Count; i++) + streamAdx.Update(bars[i]); + double streamResult = streamAdx.Last.Value; + + Assert.Equal(expected, streamResult, 9); + } + [Fact] public void TBarSeries_Update_Matches_Streaming() { diff --git a/lib/momentum/adxr/Adxr.Tests.cs b/lib/momentum/adxr/Adxr.Tests.cs index d97abe9d..6aa3ec2f 100644 --- a/lib/momentum/adxr/Adxr.Tests.cs +++ b/lib/momentum/adxr/Adxr.Tests.cs @@ -145,4 +145,130 @@ public class AdxrTests var result2 = adxr.Update(bars[0]); Assert.IsType(result2); } + + [Fact] + public void IterativeCorrections_RestoreToOriginalState() + { + var adxr = new Adxr(5); + var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1); + + // Feed 20 new values + TBar twentiethInput = default; + for (int i = 0; i < 20; i++) + { + var bar = gbm.Next(isNew: true); + twentiethInput = bar; + adxr.Update(bar, isNew: true); + } + + // Remember state after 20 values + double stateAfterTwenty = adxr.Last.Value; + + // Generate 9 corrections with isNew=false (different values) + for (int i = 0; i < 9; i++) + { + var bar = gbm.Next(isNew: false); + adxr.Update(bar, isNew: false); + } + + // Feed the remembered 20th input again with isNew=false + TValue finalResult = adxr.Update(twentiethInput, isNew: false); + + // State should match the original state after 20 values + Assert.Equal(stateAfterTwenty, finalResult.Value, 1e-10); + } + + [Fact] + public void IsHot_BecomesTrueWhenBufferFull() + { + var adxr = new Adxr(5); + var gbm = new GBM(); + + Assert.False(adxr.IsHot); + + // ADXR needs more warmup than just period (ADX warmup + period) + // Feed bars until IsHot becomes true + int count = 0; + while (!adxr.IsHot && count < 100) + { + var bar = gbm.Next(isNew: true); + adxr.Update(bar, isNew: true); + count++; + } + + Assert.True(adxr.IsHot); + Assert.True(count > 5); // Should take more than period bars + } + + [Fact] + public void NaN_Input_UsesLastValidValue() + { + var adxr = new Adxr(5); + var gbm = new GBM(); + var bars = gbm.Fetch(30, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // Feed some valid bars first + for (int i = 0; i < 25; i++) + { + adxr.Update(bars[i]); + } + + // Create a bar with NaN values + var nanBar = new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, double.NaN); + var result = adxr.Update(nanBar); + + // Should not crash and should return a finite value + Assert.True(double.IsFinite(result.Value)); + } + + [Fact] + public void Infinity_Input_UsesLastValidValue() + { + var adxr = new Adxr(5); + var gbm = new GBM(); + var bars = gbm.Fetch(30, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // Feed some valid bars first + for (int i = 0; i < 25; i++) + { + adxr.Update(bars[i]); + } + + // Create a bar with Infinity values + var infBar = new TBar(DateTime.UtcNow, double.PositiveInfinity, double.PositiveInfinity, double.NegativeInfinity, double.PositiveInfinity, double.PositiveInfinity); + var result = adxr.Update(infBar); + + // Should not crash and should return a finite value + Assert.True(double.IsFinite(result.Value)); + } + + [Fact] + public void AllModes_ProduceSameResult() + { + // Arrange + int period = 5; + var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // 1. Batch Mode (static method) + var batchSeries = Adxr.Batch(bars, period); + double expected = batchSeries.Last.Value; + + // 2. Streaming Mode (instance, one bar at a time) + var streamingInd = new Adxr(period); + for (int i = 0; i < bars.Count; i++) + { + streamingInd.Update(bars[i]); + } + double streamingResult = streamingInd.Last.Value; + + // 3. Instance Update with TBarSeries + var instanceInd = new Adxr(period); + var instanceResult = instanceInd.Update(bars); + double instanceValue = instanceResult.Last.Value; + + // Assert all modes produce identical results + Assert.Equal(expected, streamingResult, precision: 9); + Assert.Equal(expected, instanceValue, precision: 9); + } } diff --git a/lib/momentum/ao/Ao.Tests.cs b/lib/momentum/ao/Ao.Tests.cs index 960cf783..86a6815e 100644 --- a/lib/momentum/ao/Ao.Tests.cs +++ b/lib/momentum/ao/Ao.Tests.cs @@ -146,4 +146,130 @@ public class AoTests Assert.Throws(() => new Ao(5, 0)); Assert.Throws(() => new Ao(34, 5)); // Fast >= Slow } + + [Fact] + public void IterativeCorrections_RestoreToOriginalState() + { + var ao = new Ao(5, 34); + var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1); + + // Feed 50 new values (more than slow period) + TBar fiftiethInput = default; + for (int i = 0; i < 50; i++) + { + var bar = gbm.Next(isNew: true); + fiftiethInput = bar; + ao.Update(bar, isNew: true); + } + + // Remember state after 50 values + double stateAfterFifty = ao.Last.Value; + + // Generate 9 corrections with isNew=false (different values) + for (int i = 0; i < 9; i++) + { + var bar = gbm.Next(isNew: false); + ao.Update(bar, isNew: false); + } + + // Feed the remembered 50th input again with isNew=false + TValue finalResult = ao.Update(fiftiethInput, isNew: false); + + // State should match the original state after 50 values + Assert.Equal(stateAfterFifty, finalResult.Value, 1e-10); + } + + [Fact] + public void IsHot_BecomesTrueWhenBufferFull() + { + var ao = new Ao(5, 34); + var gbm = new GBM(); + + Assert.False(ao.IsHot); + + // Feed bars until IsHot becomes true + int count = 0; + while (!ao.IsHot && count < 100) + { + var bar = gbm.Next(isNew: true); + ao.Update(bar, isNew: true); + count++; + } + + Assert.True(ao.IsHot); + Assert.True(count >= 34); // Should take at least slow period bars + } + + [Fact] + public void NaN_Input_UsesLastValidValue() + { + var ao = new Ao(5, 34); + var gbm = new GBM(); + var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // Feed some valid bars first + for (int i = 0; i < 40; i++) + { + ao.Update(bars[i]); + } + + // Create a bar with NaN values + var nanBar = new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, double.NaN); + var result = ao.Update(nanBar); + + // Should not crash and should return a finite value + Assert.True(double.IsFinite(result.Value)); + } + + [Fact] + public void Infinity_Input_UsesLastValidValue() + { + var ao = new Ao(5, 34); + var gbm = new GBM(); + var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // Feed some valid bars first + for (int i = 0; i < 40; i++) + { + ao.Update(bars[i]); + } + + // Create a bar with Infinity values + var infBar = new TBar(DateTime.UtcNow, double.PositiveInfinity, double.PositiveInfinity, double.NegativeInfinity, double.PositiveInfinity, double.PositiveInfinity); + var result = ao.Update(infBar); + + // Should not crash and should return a finite value + Assert.True(double.IsFinite(result.Value)); + } + + [Fact] + public void AllModes_ProduceSameResult() + { + // Arrange + int fastPeriod = 5; + int slowPeriod = 34; + var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // 1. Batch Mode (static method) + var batchSeries = Ao.Batch(bars, fastPeriod, slowPeriod); + double expected = batchSeries.Last.Value; + + // 2. Streaming Mode (instance, one bar at a time) + var streamingInd = new Ao(fastPeriod, slowPeriod); + for (int i = 0; i < bars.Count; i++) + { + streamingInd.Update(bars[i]); + } + double streamingResult = streamingInd.Last.Value; + + // 3. Instance Update with TBarSeries + var instanceInd = new Ao(fastPeriod, slowPeriod); + var instanceResult = instanceInd.Update(bars); + double instanceValue = instanceResult.Last.Value; + + // Assert all modes produce identical results + Assert.Equal(expected, streamingResult, precision: 9); + Assert.Equal(expected, instanceValue, precision: 9); + } } diff --git a/lib/momentum/apo/Apo.Tests.cs b/lib/momentum/apo/Apo.Tests.cs index 895265d0..c295b7c4 100644 --- a/lib/momentum/apo/Apo.Tests.cs +++ b/lib/momentum/apo/Apo.Tests.cs @@ -146,4 +146,131 @@ public class ApoTests Assert.Throws(() => new Apo(12, 0)); Assert.Throws(() => new Apo(26, 12)); // Fast >= Slow } + + [Fact] + public void IterativeCorrections_RestoreToOriginalState() + { + var apo = new Apo(12, 26); + var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1); + + // Feed 50 new values (more than slow period) + TBar fiftiethInput = default; + for (int i = 0; i < 50; i++) + { + var bar = gbm.Next(isNew: true); + fiftiethInput = bar; + apo.Update(bar, isNew: true); + } + + // Remember state after 50 values + double stateAfterFifty = apo.Last.Value; + + // Generate 9 corrections with isNew=false (different values) + for (int i = 0; i < 9; i++) + { + var bar = gbm.Next(isNew: false); + apo.Update(bar, isNew: false); + } + + // Feed the remembered 50th input again with isNew=false + TValue finalResult = apo.Update(fiftiethInput, isNew: false); + + // State should match the original state after 50 values + Assert.Equal(stateAfterFifty, finalResult.Value, 1e-10); + } + + [Fact] + public void IsHot_BecomesTrueWhenBufferFull() + { + var apo = new Apo(12, 26); + var gbm = new GBM(); + + Assert.False(apo.IsHot); + + // Feed bars until IsHot becomes true + int count = 0; + while (!apo.IsHot && count < 100) + { + var bar = gbm.Next(isNew: true); + apo.Update(bar, isNew: true); + count++; + } + + Assert.True(apo.IsHot); + Assert.True(count >= 26); // Should take at least slow period bars + } + + [Fact] + public void NaN_Input_UsesLastValidValue() + { + var apo = new Apo(12, 26); + var gbm = new GBM(); + var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // Feed some valid bars first + for (int i = 0; i < 40; i++) + { + apo.Update(bars[i]); + } + + // Create a bar with NaN close value + var nanBar = new TBar(DateTime.UtcNow, 100, 105, 95, double.NaN, 1000); + var result = apo.Update(nanBar); + + // Should not crash and should return a finite value + Assert.True(double.IsFinite(result.Value)); + } + + [Fact] + public void Infinity_Input_UsesLastValidValue() + { + var apo = new Apo(12, 26); + var gbm = new GBM(); + var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // Feed some valid bars first + for (int i = 0; i < 40; i++) + { + apo.Update(bars[i]); + } + + // Create a bar with Infinity close value + var infBar = new TBar(DateTime.UtcNow, 100, 105, 95, double.PositiveInfinity, 1000); + var result = apo.Update(infBar); + + // Should not crash and should return a finite value + Assert.True(double.IsFinite(result.Value)); + } + + [Fact] + public void AllModes_ProduceSameResult() + { + // Arrange + int fastPeriod = 12; + int slowPeriod = 26; + var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var closeSeries = bars.Close; + + // 1. Batch Mode (static method) + var batchSeries = Apo.Batch(closeSeries, fastPeriod, slowPeriod); + double expected = batchSeries.Last.Value; + + // 2. Streaming Mode (instance, one bar at a time) + var streamingInd = new Apo(fastPeriod, slowPeriod); + for (int i = 0; i < bars.Count; i++) + { + streamingInd.Update(bars[i]); + } + double streamingResult = streamingInd.Last.Value; + + // 3. Instance Update with TSeries + var instanceInd = new Apo(fastPeriod, slowPeriod); + var instanceResult = instanceInd.Update(closeSeries); + double instanceValue = instanceResult.Last.Value; + + // Assert all modes produce identical results + Assert.Equal(expected, streamingResult, precision: 9); + Assert.Equal(expected, instanceValue, precision: 9); + } } diff --git a/lib/momentum/aroon/Aroon.Tests.cs b/lib/momentum/aroon/Aroon.Tests.cs index 845ed846..e401787c 100644 --- a/lib/momentum/aroon/Aroon.Tests.cs +++ b/lib/momentum/aroon/Aroon.Tests.cs @@ -162,4 +162,137 @@ public class AroonTests Assert.Equal(100.0, aroon.Down.Value, 1e-9); Assert.Equal(-50.0, result.Value, 1e-9); } + + [Fact] + public void IterativeCorrections_RestoreToOriginalState() + { + var aroon = new Aroon(5); + var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1); + + // Feed 20 new values + TBar twentiethInput = default; + for (int i = 0; i < 20; i++) + { + var bar = gbm.Next(isNew: true); + twentiethInput = bar; + aroon.Update(bar, isNew: true); + } + + // Remember state after 20 values + double stateAfterTwenty = aroon.Last.Value; + double upAfterTwenty = aroon.Up.Value; + double downAfterTwenty = aroon.Down.Value; + + // Generate 9 corrections with isNew=false (different values) + for (int i = 0; i < 9; i++) + { + var bar = gbm.Next(isNew: false); + aroon.Update(bar, isNew: false); + } + + // Feed the remembered 20th input again with isNew=false + TValue finalResult = aroon.Update(twentiethInput, isNew: false); + + // State should match the original state after 20 values + Assert.Equal(stateAfterTwenty, finalResult.Value, 1e-10); + Assert.Equal(upAfterTwenty, aroon.Up.Value, 1e-10); + Assert.Equal(downAfterTwenty, aroon.Down.Value, 1e-10); + } + + [Fact] + public void IsHot_BecomesTrueWhenBufferFull() + { + var aroon = new Aroon(5); + var gbm = new GBM(); + + Assert.False(aroon.IsHot); + + // Feed bars until IsHot becomes true + int count = 0; + while (!aroon.IsHot && count < 50) + { + var bar = gbm.Next(isNew: true); + aroon.Update(bar, isNew: true); + count++; + } + + Assert.True(aroon.IsHot); + Assert.True(count >= 5); // Should take at least period bars + } + + [Fact] + public void NaN_Input_UsesLastValidValue() + { + var aroon = new Aroon(5); + var gbm = new GBM(); + var bars = gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // Feed some valid bars first + for (int i = 0; i < 15; i++) + { + aroon.Update(bars[i]); + } + + // Create a bar with NaN values + var nanBar = new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, double.NaN); + var result = aroon.Update(nanBar); + + // Should not crash and should return a finite value + Assert.True(double.IsFinite(result.Value)); + Assert.True(double.IsFinite(aroon.Up.Value)); + Assert.True(double.IsFinite(aroon.Down.Value)); + } + + [Fact] + public void Infinity_Input_UsesLastValidValue() + { + var aroon = new Aroon(5); + var gbm = new GBM(); + var bars = gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // Feed some valid bars first + for (int i = 0; i < 15; i++) + { + aroon.Update(bars[i]); + } + + // Create a bar with Infinity values + var infBar = new TBar(DateTime.UtcNow, double.PositiveInfinity, double.PositiveInfinity, double.NegativeInfinity, double.PositiveInfinity, double.PositiveInfinity); + var result = aroon.Update(infBar); + + // Should not crash and should return a finite value + Assert.True(double.IsFinite(result.Value)); + Assert.True(double.IsFinite(aroon.Up.Value)); + Assert.True(double.IsFinite(aroon.Down.Value)); + } + + [Fact] + public void AllModes_ProduceSameResult() + { + // Arrange + int period = 14; + var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // 1. Batch Mode (static method) + var batchSeries = Aroon.Batch(bars, period); + double expected = batchSeries.Last.Value; + + // 2. Streaming Mode (instance, one bar at a time) + var streamingInd = new Aroon(period); + for (int i = 0; i < bars.Count; i++) + { + streamingInd.Update(bars[i]); + } + double streamingResult = streamingInd.Last.Value; + + // 3. Instance Update with TBarSeries + var instanceInd = new Aroon(period); + var instanceResult = instanceInd.Update(bars); + double instanceValue = instanceResult.Last.Value; + + // Assert all modes produce identical results + Assert.Equal(expected, streamingResult, precision: 9); + Assert.Equal(expected, instanceValue, precision: 9); + } } diff --git a/lib/momentum/aroonosc/AroonOsc.Tests.cs b/lib/momentum/aroonosc/AroonOsc.Tests.cs index 2e3c6b31..fa646121 100644 --- a/lib/momentum/aroonosc/AroonOsc.Tests.cs +++ b/lib/momentum/aroonosc/AroonOsc.Tests.cs @@ -156,4 +156,129 @@ public class AroonOscTests Assert.Equal(-50.0, result.Value, 1e-9); } + + [Fact] + public void IterativeCorrections_RestoreToOriginalState() + { + var aroon = new AroonOsc(5); + var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1); + + // Feed 20 new values + TBar twentiethInput = default; + for (int i = 0; i < 20; i++) + { + var bar = gbm.Next(isNew: true); + twentiethInput = bar; + aroon.Update(bar, isNew: true); + } + + // Remember state after 20 values + double stateAfterTwenty = aroon.Last.Value; + + // Generate 9 corrections with isNew=false (different values) + for (int i = 0; i < 9; i++) + { + var bar = gbm.Next(isNew: false); + aroon.Update(bar, isNew: false); + } + + // Feed the remembered 20th input again with isNew=false + TValue finalResult = aroon.Update(twentiethInput, isNew: false); + + // State should match the original state after 20 values + Assert.Equal(stateAfterTwenty, finalResult.Value, 1e-10); + } + + [Fact] + public void IsHot_BecomesTrueWhenBufferFull() + { + var aroon = new AroonOsc(5); + var gbm = new GBM(); + + Assert.False(aroon.IsHot); + + // Feed bars until IsHot becomes true + int count = 0; + while (!aroon.IsHot && count < 50) + { + var bar = gbm.Next(isNew: true); + aroon.Update(bar, isNew: true); + count++; + } + + Assert.True(aroon.IsHot); + Assert.True(count >= 5); // Should take at least period bars + } + + [Fact] + public void NaN_Input_UsesLastValidValue() + { + var aroon = new AroonOsc(5); + var gbm = new GBM(); + var bars = gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // Feed some valid bars first + for (int i = 0; i < 15; i++) + { + aroon.Update(bars[i]); + } + + // Create a bar with NaN values + var nanBar = new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, double.NaN); + var result = aroon.Update(nanBar); + + // Should not crash and should return a finite value + Assert.True(double.IsFinite(result.Value)); + } + + [Fact] + public void Infinity_Input_UsesLastValidValue() + { + var aroon = new AroonOsc(5); + var gbm = new GBM(); + var bars = gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // Feed some valid bars first + for (int i = 0; i < 15; i++) + { + aroon.Update(bars[i]); + } + + // Create a bar with Infinity values + var infBar = new TBar(DateTime.UtcNow, double.PositiveInfinity, double.PositiveInfinity, double.NegativeInfinity, double.PositiveInfinity, double.PositiveInfinity); + var result = aroon.Update(infBar); + + // Should not crash and should return a finite value + Assert.True(double.IsFinite(result.Value)); + } + + [Fact] + public void AllModes_ProduceSameResult() + { + // Arrange + int period = 14; + var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // 1. Batch Mode (static method) + var batchSeries = AroonOsc.Batch(bars, period); + double expected = batchSeries.Last.Value; + + // 2. Streaming Mode (instance, one bar at a time) + var streamingInd = new AroonOsc(period); + for (int i = 0; i < bars.Count; i++) + { + streamingInd.Update(bars[i]); + } + double streamingResult = streamingInd.Last.Value; + + // 3. Instance Update with TBarSeries + var instanceInd = new AroonOsc(period); + var instanceResult = instanceInd.Update(bars); + double instanceValue = instanceResult.Last.Value; + + // Assert all modes produce identical results + Assert.Equal(expected, streamingResult, precision: 9); + Assert.Equal(expected, instanceValue, precision: 9); + } } diff --git a/lib/momentum/bop/Bop.Tests.cs b/lib/momentum/bop/Bop.Tests.cs index 82b9d243..71c9af41 100644 --- a/lib/momentum/bop/Bop.Tests.cs +++ b/lib/momentum/bop/Bop.Tests.cs @@ -59,6 +59,116 @@ public class BopTests Assert.Equal(-1, result.Value); } + [Fact] + public void Calc_IsNew_AcceptsParameter() + { + // BOP is stateless - each bar is calculated independently + // isNew parameter is accepted but doesn't affect stateless calculation + var bop = new Bop(); + // bar1: BOP = (15-10)/(20-5) = 5/15 = 0.333 + var bar1 = new TBar(DateTime.UtcNow, 10, 20, 5, 15, 100); + // bar2: BOP = (25-15)/(30-10) = 10/20 = 0.5 + var bar2 = new TBar(DateTime.UtcNow, 15, 30, 10, 25, 100); + + bop.Update(bar1, isNew: true); + var val1 = bop.Last.Value; + + bop.Update(bar2, isNew: true); + var val2 = bop.Last.Value; + + // Different bars produce different BOP values + Assert.NotEqual(val1, val2); + Assert.Equal(1.0 / 3.0, val1, 6); // bar1 BOP + Assert.Equal(0.5, val2, 6); // bar2 BOP + } + + [Fact] + public void Calc_IsNew_False_UpdatesValue() + { + // BOP is stateless - each bar is calculated independently + // isNew=false still calculates the new value + var bop = new Bop(); + var bar1 = new TBar(DateTime.UtcNow, 10, 20, 5, 15, 100); + var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 10, 25, 5, 20, 100); + + var val1 = bop.Update(bar1, isNew: true); + var val2 = bop.Update(bar2, isNew: false); + + // Different bars produce different values (BOP has no state to preserve) + Assert.NotEqual(val1.Value, val2.Value); + } + + [Fact] + public void IterativeCorrections_RestoreToOriginalState() + { + var bop = new Bop(); + var bar = new TBar(DateTime.UtcNow, 10, 20, 5, 15, 100); + + var originalValue = bop.Update(bar, isNew: true); + + for (int i = 0; i < 5; i++) + { + var modified = new TBar(bar.Time, bar.Open, bar.High + i, bar.Low, bar.Close, bar.Volume); + bop.Update(modified, isNew: false); + } + + var restored = bop.Update(bar, isNew: false); + Assert.Equal(originalValue.Value, restored.Value, 9); + } + + [Fact] + public void Reset_ClearsState() + { + var bop = new Bop(); + var bar = new TBar(DateTime.UtcNow, 10, 20, 5, 15, 100); + + bop.Update(bar); + bop.Reset(); + + Assert.Equal(0, bop.Last.Value); + } + + [Fact] + public void IsHot_AlwaysTrueForBop() + { + // BOP has no warmup - IsHot is always true (static property) + Assert.True(Bop.IsHot); + + var bop = new Bop(); + var bar = new TBar(DateTime.UtcNow, 10, 20, 5, 15, 100); + bop.Update(bar); + + Assert.True(Bop.IsHot); + } + + [Fact] + public void NaN_Input_ProducesNaN() + { + // BOP is stateless and doesn't track last valid value + // NaN input propagates through the calculation + var bop = new Bop(); + var barNaN = new TBar(DateTime.UtcNow, double.NaN, 20, 5, 15, 100); + + var result = bop.Update(barNaN); + + // BOP = (Close - Open) / (High - Low) = (15 - NaN) / (20 - 5) = NaN + Assert.True(double.IsNaN(result.Value)); + } + + [Fact] + public void Infinity_Input_ProducesInfinity() + { + // BOP is stateless and doesn't track last valid value + // Infinity input propagates through the calculation + var bop = new Bop(); + var barInf = new TBar(DateTime.UtcNow, double.PositiveInfinity, 20, 5, 15, 100); + + var result = bop.Update(barInf); + + // BOP = (Close - Open) / (High - Low) = (15 - Infinity) / (20 - 5) = -Infinity + Assert.True(double.IsInfinity(result.Value)); + } + [Fact] public void BatchMatchesStreaming() { @@ -92,4 +202,48 @@ public class BopTests Assert.Equal(batchResult[0].Value, output[0]); Assert.Equal(batchResult[1].Value, output[1]); } + + [Fact] + public void AllModes_ProduceSameResult() + { + var gbm = new GBM(seed: 123); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // 1. Batch Mode + var batchResult = Bop.Batch(bars); + double expected = batchResult.Last.Value; + + // 2. Span Mode + var spanOutput = new double[bars.Count]; + Bop.Calculate(bars.Open.Values, bars.High.Values, bars.Low.Values, bars.Close.Values, spanOutput); + double spanResult = spanOutput[^1]; + + // 3. Streaming Mode + var streamBop = new Bop(); + for (int i = 0; i < bars.Count; i++) + streamBop.Update(bars[i]); + double streamResult = streamBop.Last.Value; + + Assert.Equal(expected, spanResult, 9); + Assert.Equal(expected, streamResult, 9); + } + + [Fact] + public void SpanBatch_ProcessesMinimumLength() + { + // BOP.Calculate processes the minimum length of all arrays + // It doesn't throw when output is smaller - it just processes fewer elements + double[] open = [1, 2, 3]; + double[] high = [2, 3, 4]; + double[] low = [0, 1, 2]; + double[] close = [1.5, 2.5, 3.5]; + double[] smallOutput = new double[2]; + + // This should process 2 elements (minimum of all array lengths) + Bop.Calculate(open, high, low, close, smallOutput); + + // Verify values are calculated for the first 2 elements + Assert.Equal(0.25, smallOutput[0], 6); // (1.5 - 1) / (2 - 0) = 0.5/2 = 0.25 + Assert.Equal(0.25, smallOutput[1], 6); // (2.5 - 2) / (3 - 1) = 0.5/2 = 0.25 + } } diff --git a/lib/momentum/cfb/Cfb.Tests.cs b/lib/momentum/cfb/Cfb.Tests.cs index ce1bffea..1792aa00 100644 --- a/lib/momentum/cfb/Cfb.Tests.cs +++ b/lib/momentum/cfb/Cfb.Tests.cs @@ -6,6 +6,24 @@ namespace QuanTAlib; public class CfbTests { + [Fact] + public void Constructor_EmptyLengths_UsesDefaults() + { + // Cfb uses default lengths (2, 4, ..., 192) when given empty or null lengths + var cfb = new Cfb(Array.Empty()); + Assert.NotNull(cfb); + Assert.Equal("Jurik Composite Fractal Behavior", cfb.Name); + } + + [Fact] + public void Constructor_CustomLengths_Works() + { + // Cfb accepts custom lengths + var cfb = new Cfb(new[] { 5, 10, 20 }); + Assert.NotNull(cfb); + Assert.Equal("Jurik Composite Fractal Behavior", cfb.Name); + } + [Fact] public void BasicCalculation_DoesNotCrash() { @@ -110,6 +128,81 @@ public class CfbTests Assert.Equal(val3.Value, val2.Value); } + [Fact] + public void IterativeCorrections_RestoreToOriginalState() + { + var cfb = new Cfb(); + var gbm = new GBM(); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 50; i++) + cfb.Update(new TValue(bars.Close.Times[i], bars.Close.Values[i])); + + var originalValue = cfb.Last; + + for (int m = 0; m < 5; m++) + { + var modified = new TValue(bars.Close.Times[49], bars.Close.Values[49] + m); + cfb.Update(modified, isNew: false); + } + + var restored = cfb.Update(new TValue(bars.Close.Times[49], bars.Close.Values[49]), isNew: false); + Assert.Equal(originalValue.Value, restored.Value, 9); + } + + [Fact] + public void NaN_Input_UsesLastValidValue() + { + var cfb = new Cfb(); + var gbm = new GBM(); + var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 30; i++) + cfb.Update(new TValue(bars.Close.Times[i], bars.Close.Values[i])); + + var result = cfb.Update(new TValue(DateTime.UtcNow, double.NaN)); + Assert.True(double.IsFinite(result.Value)); + } + + [Fact] + public void Infinity_Input_UsesLastValidValue() + { + var cfb = new Cfb(); + var gbm = new GBM(); + var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 30; i++) + cfb.Update(new TValue(bars.Close.Times[i], bars.Close.Values[i])); + + var result = cfb.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity)); + Assert.True(double.IsFinite(result.Value)); + } + + [Fact] + public void AllModes_ProduceSameResult() + { + var gbm = new GBM(seed: 123); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // 1. Batch Mode + var batchResult = Cfb.Batch(bars.Close); + double expected = batchResult.Last.Value; + + // 2. Span Mode + var spanOutput = new double[bars.Count]; + Cfb.Batch(bars.Close.Values.ToArray(), spanOutput); + double spanResult = spanOutput[^1]; + + // 3. Streaming Mode + var streamCfb = new Cfb(); + for (int i = 0; i < bars.Count; i++) + streamCfb.Update(new TValue(bars.Close.Times[i], bars.Close.Values[i])); + double streamResult = streamCfb.Last.Value; + + Assert.Equal(expected, spanResult, 9); + Assert.Equal(expected, streamResult, 9); + } + [Fact] public void StaticBatch_Matches_Streaming() { diff --git a/lib/momentum/dmx/Dmx.Tests.cs b/lib/momentum/dmx/Dmx.Tests.cs index ec5b357c..52732d10 100644 --- a/lib/momentum/dmx/Dmx.Tests.cs +++ b/lib/momentum/dmx/Dmx.Tests.cs @@ -6,6 +6,17 @@ namespace QuanTAlib; public class DmxTests { + [Fact] + public void Constructor_InvalidParameters_ThrowsException() + { + // Dmx delegates to Jma which throws ArgumentOutOfRangeException (subclass of ArgumentException) + var ex1 = Assert.ThrowsAny(() => new Dmx(0)); + Assert.Contains("period", ex1.Message, StringComparison.OrdinalIgnoreCase); + + var ex2 = Assert.ThrowsAny(() => new Dmx(-1)); + Assert.Contains("period", ex2.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void BasicCalculation_DoesNotCrash() { @@ -52,6 +63,28 @@ public class DmxTests Assert.Equal(val3.Value, val2.Value, 1e-9); } + [Fact] + public void IterativeCorrections_RestoreToOriginalState() + { + var dmx = new Dmx(14); + var gbm = new GBM(); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 50; i++) + dmx.Update(bars[i]); + + var originalValue = dmx.Last; + + for (int m = 0; m < 5; m++) + { + var modified = new TBar(bars[49].Time, bars[49].Open, bars[49].High + m, bars[49].Low - m, bars[49].Close, bars[49].Volume); + dmx.Update(modified, isNew: false); + } + + var restored = dmx.Update(bars[49], isNew: false); + Assert.Equal(originalValue.Value, restored.Value, 9); + } + [Fact] public void Reset_Works() { @@ -76,6 +109,57 @@ public class DmxTests Assert.True(double.IsFinite(dmx.Last.Value)); } + [Fact] + public void NaN_Input_UsesLastValidValue() + { + var dmx = new Dmx(14); + var gbm = new GBM(); + var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 30; i++) + dmx.Update(bars[i]); + + var nanBar = new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, 100); + var result = dmx.Update(nanBar); + + Assert.True(double.IsFinite(result.Value)); + } + + [Fact] + public void Infinity_Input_UsesLastValidValue() + { + var dmx = new Dmx(14); + var gbm = new GBM(); + var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 30; i++) + dmx.Update(bars[i]); + + var infBar = new TBar(DateTime.UtcNow, double.PositiveInfinity, double.PositiveInfinity, 0, 100, 100); + var result = dmx.Update(infBar); + + Assert.True(double.IsFinite(result.Value)); + } + + [Fact] + public void AllModes_ProduceSameResult() + { + var gbm = new GBM(seed: 123); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // 1. Batch Mode + var batchResult = Dmx.Batch(bars, 14); + double expected = batchResult.Last.Value; + + // 2. Streaming Mode + var streamDmx = new Dmx(14); + for (int i = 0; i < bars.Count; i++) + streamDmx.Update(bars[i]); + double streamResult = streamDmx.Last.Value; + + Assert.Equal(expected, streamResult, 9); + } + [Fact] public void TBarSeries_Update_Matches_Streaming() { diff --git a/lib/momentum/macd/Macd.Quantower.Tests.cs b/lib/momentum/macd/Macd.Quantower.Tests.cs index 559701be..fa0c2443 100644 --- a/lib/momentum/macd/Macd.Quantower.Tests.cs +++ b/lib/momentum/macd/Macd.Quantower.Tests.cs @@ -26,7 +26,7 @@ public class MacdIndicatorTests { FastPeriod = 12, SlowPeriod = 26, - SignalPeriod = 9 + SignalPeriod = 9, }; // 26 + 9 = 35 @@ -72,7 +72,7 @@ public class MacdIndicatorTests { FastPeriod = 2, SlowPeriod = 5, - SignalPeriod = 2 + SignalPeriod = 2, }; indicator.Initialize(); diff --git a/lib/momentum/macd/Macd.Tests.cs b/lib/momentum/macd/Macd.Tests.cs index e08dbe1c..5781ff7f 100644 --- a/lib/momentum/macd/Macd.Tests.cs +++ b/lib/momentum/macd/Macd.Tests.cs @@ -12,12 +12,148 @@ public class MacdTests Assert.False(macd.IsHot); } + [Fact] + public void Constructor_ValidParameters_Works() + { + // Macd delegates to Ema which handles validation + // Testing that valid parameters work correctly + var macd = new Macd(12, 26, 9); + Assert.NotNull(macd); + Assert.Equal("Macd(12,26,9)", macd.Name); + Assert.Equal(35, macd.WarmupPeriod); // max(12,26) + 9 = 35 + } + + [Fact] + public void Constructor_CustomParameters_Works() + { + var macd = new Macd(5, 10, 3); + Assert.NotNull(macd); + Assert.Equal("Macd(5,10,3)", macd.Name); + Assert.Equal(13, macd.WarmupPeriod); // max(5,10) + 3 = 13 + } + + [Fact] + public void Calc_IsNew_AcceptsParameter() + { + var macd = new Macd(12, 26, 9); + var gbm = new GBM(); + var series = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 49; i++) + macd.Update(series.Close[i], isNew: true); + + var val1 = macd.Update(series.Close[49], isNew: true); + var val2 = macd.Update(new TValue(DateTime.UtcNow, series.Close[49].Value + 1), isNew: true); + + Assert.NotEqual(val1.Value, val2.Value); + } + + [Fact] + public void Calc_IsNew_False_UpdatesValue() + { + var macd = new Macd(12, 26, 9); + var gbm = new GBM(); + var series = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 49; i++) + macd.Update(series.Close[i]); + + var val1 = macd.Update(series.Close[49], isNew: true); + var val2 = macd.Update(new TValue(series.Close[49].Time, series.Close[49].Value + 5), isNew: false); + + Assert.Equal(val1.Time, val2.Time); + Assert.NotEqual(val1.Value, val2.Value); + } + + [Fact] + public void IterativeCorrections_RestoreToOriginalState() + { + var macd = new Macd(12, 26, 9); + var gbm = new GBM(); + var series = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 50; i++) + macd.Update(series.Close[i]); + + var originalValue = macd.Last; + + for (int m = 0; m < 5; m++) + { + var modified = new TValue(series.Close[49].Time, series.Close[49].Value + m); + macd.Update(modified, isNew: false); + } + + var restored = macd.Update(series.Close[49], isNew: false); + Assert.Equal(originalValue.Value, restored.Value, 9); + } + + [Fact] + public void Reset_ClearsState() + { + var macd = new Macd(12, 26, 9); + var gbm = new GBM(); + var series = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < series.Count; i++) + macd.Update(series.Close[i]); + + macd.Reset(); + + Assert.Equal(0, macd.Last.Value); + Assert.False(macd.IsHot); + } + + [Fact] + public void IsHot_BecomesTrueWhenBufferFull() + { + var macd = new Macd(12, 26, 9); + var gbm = new GBM(); + var series = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + Assert.False(macd.IsHot); + + for (int i = 0; i < series.Count; i++) + { + macd.Update(series.Close[i]); + if (i >= 40) break; + } + + Assert.True(macd.IsHot); + } + + [Fact] + public void NaN_Input_UsesLastValidValue() + { + var macd = new Macd(12, 26, 9); + var gbm = new GBM(); + var series = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 40; i++) + macd.Update(series.Close[i]); + + var result = macd.Update(new TValue(DateTime.UtcNow, double.NaN)); + Assert.True(double.IsFinite(result.Value)); + } + + [Fact] + public void Infinity_Input_UsesLastValidValue() + { + var macd = new Macd(12, 26, 9); + var gbm = new GBM(); + var series = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < 40; i++) + macd.Update(series.Close[i]); + + var result = macd.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity)); + Assert.True(double.IsFinite(result.Value)); + } + [Fact] public void BatchMatchesStreaming() { var macd = new Macd(12, 26, 9); var series = new TSeries(); - // Generate some data for (int i = 0; i < 100; i++) { series.Add(new TValue(DateTime.UtcNow.AddMinutes(i), 100 + Math.Sin(i * 0.1) * 10)); @@ -44,7 +180,6 @@ public class MacdTests { var macd = new Macd(12, 26, 9); var series = new TSeries(); - // Generate some data for (int i = 0; i < 100; i++) { series.Add(new TValue(DateTime.UtcNow.AddMinutes(i), 100 + Math.Sin(i * 0.1) * 10)); @@ -60,4 +195,50 @@ public class MacdTests Assert.Equal(batchResult[i].Value, output[i], 8); } } + + [Fact] + public void AllModes_ProduceSameResult() + { + var gbm = new GBM(seed: 123); + var series = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // 1. Batch Mode + var batchMacd = new Macd(12, 26, 9); + var batchResult = batchMacd.Update(series.Close); + double expected = batchResult.Last.Value; + + // 2. Span Mode + var spanOutput = new double[series.Count]; + Macd.Calculate(series.Close.Values, spanOutput, 12, 26); + double spanResult = spanOutput[^1]; + + // 3. Streaming Mode + var streamMacd = new Macd(12, 26, 9); + for (int i = 0; i < series.Count; i++) + streamMacd.Update(series.Close[i]); + double streamResult = streamMacd.Last.Value; + + // 4. Eventing Mode + var pubSource = new TSeries(); + var eventMacd = new Macd(pubSource, 12, 26, 9); + for (int i = 0; i < series.Count; i++) + pubSource.Add(series.Close[i]); + double eventResult = eventMacd.Last.Value; + + Assert.Equal(expected, spanResult, 9); + Assert.Equal(expected, streamResult, 9); + Assert.Equal(expected, eventResult, 9); + } + + [Fact] + public void SpanBatch_ValidatesInput() + { + double[] source = [1, 2, 3, 4, 5]; + double[] output = new double[5]; + double[] wrongSize = new double[3]; + + Assert.Throws(() => Macd.Calculate(source, wrongSize, 12, 26)); + Assert.Throws(() => Macd.Calculate(source, output, 0, 26)); + Assert.Throws(() => Macd.Calculate(source, output, 12, 0)); + } } diff --git a/lib/momentum/rsi/Rsi.Quantower.Tests.cs b/lib/momentum/rsi/Rsi.Quantower.Tests.cs index d55d7847..0da8c0ce 100644 --- a/lib/momentum/rsi/Rsi.Quantower.Tests.cs +++ b/lib/momentum/rsi/Rsi.Quantower.Tests.cs @@ -22,7 +22,7 @@ public class RsiIndicatorTests { var indicator = new RsiIndicator { - Period = 20 + Period = 20, }; Assert.Equal(0, RsiIndicator.MinHistoryDepths); @@ -35,7 +35,7 @@ public class RsiIndicatorTests { var indicator = new RsiIndicator { - Period = 20 + Period = 20, }; indicator.Initialize(); @@ -68,7 +68,7 @@ public class RsiIndicatorTests { var indicator = new RsiIndicator { - Period = 2 // Short period for testing + Period = 2, // Short period for testing }; indicator.Initialize(); diff --git a/lib/momentum/rsx/Rsx.Tests.cs b/lib/momentum/rsx/Rsx.Tests.cs index f76f5f45..0e1394c0 100644 --- a/lib/momentum/rsx/Rsx.Tests.cs +++ b/lib/momentum/rsx/Rsx.Tests.cs @@ -127,4 +127,109 @@ public class RsxTests var result = rsx2.Update(new TValue(DateTime.UtcNow, 100)); Assert.False(double.IsNaN(result.Value)); } + + [Fact] + public void IterativeCorrections_RestoreToOriginalState() + { + var rsx = new Rsx(5); + var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1); + + // Feed 20 new values + TValue twentiethInput = default; + for (int i = 0; i < 20; i++) + { + var bar = gbm.Next(isNew: true); + twentiethInput = new TValue(bar.Time, bar.Close); + rsx.Update(twentiethInput, isNew: true); + } + + // Remember state after 20 values + double stateAfterTwenty = rsx.Last.Value; + + // Generate 9 corrections with isNew=false (different values) + for (int i = 0; i < 9; i++) + { + var bar = gbm.Next(isNew: false); + rsx.Update(new TValue(bar.Time, bar.Close), isNew: false); + } + + // Feed the remembered 20th input again with isNew=false + TValue finalResult = rsx.Update(twentiethInput, isNew: false); + + // State should match the original state after 20 values + Assert.Equal(stateAfterTwenty, finalResult.Value, 1e-10); + } + + [Fact] + public void IsHot_BecomesTrueAfterFirstValue() + { + var rsx = new Rsx(5); + + Assert.False(rsx.IsHot); + + // RSX uses IsInitialized for IsHot, which becomes true after first value + rsx.Update(new TValue(DateTime.UtcNow, 100), isNew: true); + + Assert.True(rsx.IsHot); + } + + [Fact] + public void Infinity_Input_UsesLastValidValue() + { + var rsx = new Rsx(14); + rsx.Update(new TValue(DateTime.UtcNow, 100)); + rsx.Update(new TValue(DateTime.UtcNow, 110)); + + var resultAfterPosInf = rsx.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity)); + Assert.False(double.IsNaN(resultAfterPosInf.Value)); + Assert.True(double.IsFinite(resultAfterPosInf.Value)); + Assert.InRange(resultAfterPosInf.Value, 0, 100); + + var resultAfterNegInf = rsx.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity)); + Assert.False(double.IsNaN(resultAfterNegInf.Value)); + Assert.True(double.IsFinite(resultAfterNegInf.Value)); + Assert.InRange(resultAfterNegInf.Value, 0, 100); + } + + [Fact] + public void AllModes_ProduceSameResult() + { + // Arrange + int period = 14; + var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; + + // 1. Batch Mode (static method) + var batchSeries = Rsx.Batch(series, period); + double expected = batchSeries.Last.Value; + + // 2. Span Mode (static method with spans) + var spanInput = series.Values.ToArray(); + var spanOutput = new double[spanInput.Length]; + Rsx.Batch(spanInput, spanOutput, period); + double spanResult = spanOutput[^1]; + + // 3. Streaming Mode (instance, one value at a time) + var streamingInd = new Rsx(period); + for (int i = 0; i < series.Count; i++) + { + streamingInd.Update(series[i]); + } + double streamingResult = streamingInd.Last.Value; + + // 4. Eventing Mode (chained via ITValuePublisher) + var pubSource = new TSeries(); + var eventingInd = new Rsx(pubSource, period); + for (int i = 0; i < series.Count; i++) + { + pubSource.Add(series[i]); + } + double eventingResult = eventingInd.Last.Value; + + // Assert all modes produce identical results + Assert.Equal(expected, spanResult, precision: 9); + Assert.Equal(expected, streamingResult, precision: 9); + Assert.Equal(expected, eventingResult, precision: 9); + } } diff --git a/lib/momentum/vel/Vel.Tests.cs b/lib/momentum/vel/Vel.Tests.cs index 98dd5a7a..d66bc5eb 100644 --- a/lib/momentum/vel/Vel.Tests.cs +++ b/lib/momentum/vel/Vel.Tests.cs @@ -161,4 +161,119 @@ public class VelTests vel.Update(new TValue(DateTime.UtcNow, 100)); Assert.False(double.IsNaN(vel2.Last.Value)); } + + [Fact] + public void IterativeCorrections_RestoreToOriginalState() + { + var vel = new Vel(5); + var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1); + + // Feed 20 new values + TValue twentiethInput = default; + for (int i = 0; i < 20; i++) + { + var bar = gbm.Next(isNew: true); + twentiethInput = new TValue(bar.Time, bar.Close); + vel.Update(twentiethInput, isNew: true); + } + + // Remember state after 20 values + double stateAfterTwenty = vel.Last.Value; + + // Generate 9 corrections with isNew=false (different values) + for (int i = 0; i < 9; i++) + { + var bar = gbm.Next(isNew: false); + vel.Update(new TValue(bar.Time, bar.Close), isNew: false); + } + + // Feed the remembered 20th input again with isNew=false + TValue finalResult = vel.Update(twentiethInput, isNew: false); + + // State should match the original state after 20 values + Assert.Equal(stateAfterTwenty, finalResult.Value, 1e-10); + } + + [Fact] + public void NaN_Input_UsesLastValidValue() + { + var vel = new Vel(5); + var gbm = new GBM(); + var bars = gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // Feed some valid values first + for (int i = 0; i < 15; i++) + { + vel.Update(new TValue(bars[i].Time, bars[i].Close)); + } + + // Feed NaN + var result = vel.Update(new TValue(DateTime.UtcNow, double.NaN)); + + // Should not crash and should return a finite value + Assert.True(double.IsFinite(result.Value)); + } + + [Fact] + public void Infinity_Input_UsesLastValidValue() + { + var vel = new Vel(5); + var gbm = new GBM(); + var bars = gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // Feed some valid values first + for (int i = 0; i < 15; i++) + { + vel.Update(new TValue(bars[i].Time, bars[i].Close)); + } + + // Feed Infinity + var resultPos = vel.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity)); + Assert.True(double.IsFinite(resultPos.Value)); + + var resultNeg = vel.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity)); + Assert.True(double.IsFinite(resultNeg.Value)); + } + + [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(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + var series = bars.Close; + + // 1. Batch Mode (static method) + var batchSeries = Vel.Batch(series, period); + double expected = batchSeries.Last.Value; + + // 2. Span Mode (static method with spans) + var spanInput = series.Values.ToArray(); + var spanOutput = new double[spanInput.Length]; + Vel.Batch(spanInput.AsSpan(), spanOutput.AsSpan(), period); + double spanResult = spanOutput[^1]; + + // 3. Streaming Mode (instance, one value at a time) + 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 (chained via ITValuePublisher) + 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 all modes produce identical results + Assert.Equal(expected, spanResult, precision: 9); + Assert.Equal(expected, streamingResult, precision: 9); + Assert.Equal(expected, eventingResult, precision: 9); + } } diff --git a/lib/trends/bessel/Bessel.Quantower.Tests.cs b/lib/trends/bessel/Bessel.Quantower.Tests.cs index b92a08c0..3aab5cf0 100644 --- a/lib/trends/bessel/Bessel.Quantower.Tests.cs +++ b/lib/trends/bessel/Bessel.Quantower.Tests.cs @@ -132,7 +132,7 @@ public class BesselIndicatorTests SourceType.Low, SourceType.Close, SourceType.HL2, - SourceType.HLC3 + SourceType.HLC3, }; foreach (var source in sources) diff --git a/lib/trends/bessel/Bessel.cs b/lib/trends/bessel/Bessel.cs index 6f013347..de716300 100644 --- a/lib/trends/bessel/Bessel.cs +++ b/lib/trends/bessel/Bessel.cs @@ -32,7 +32,7 @@ public sealed class Bessel : AbstractBase, IDisposable F2 = 0, LastValidValue = 0, Count = 0, - IsHot = false + IsHot = false, }; } diff --git a/lib/trends/ema/Ema.cs b/lib/trends/ema/Ema.cs index b26ac39c..031489d8 100644 --- a/lib/trends/ema/Ema.cs +++ b/lib/trends/ema/Ema.cs @@ -303,7 +303,6 @@ public sealed class Ema : AbstractBase else val = lastValidValue; - state.Ema = Math.FusedMultiplyAdd(state.Ema, decay, alpha * val); state.E *= decay; diff --git a/lib/trends/pwma/Pwma.cs b/lib/trends/pwma/Pwma.cs index 5baf69ec..6da647fc 100644 --- a/lib/trends/pwma/Pwma.cs +++ b/lib/trends/pwma/Pwma.cs @@ -65,10 +65,12 @@ public sealed class Pwma : AbstractBase private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew); [MethodImpl(MethodImplOptions.AggressiveInlining)] +#pragma warning disable RCS1032 // Remove redundant parentheses private double GetValidValue(double input, double lastValid) { return double.IsFinite(input) ? input : lastValid; } +#pragma warning restore RCS1032 [MethodImpl(MethodImplOptions.AggressiveInlining)] private void UpdateLastValidValue(double val) diff --git a/lib/volume/adosc/Adosc.Quantower.Tests.cs b/lib/volume/adosc/Adosc.Quantower.Tests.cs index d871fd68..d5cc75fa 100644 --- a/lib/volume/adosc/Adosc.Quantower.Tests.cs +++ b/lib/volume/adosc/Adosc.Quantower.Tests.cs @@ -24,7 +24,7 @@ public class AdoscIndicatorTests { var indicator = new AdoscIndicator { - SlowPeriod = 20 + SlowPeriod = 20, }; Assert.Equal(0, AdoscIndicator.MinHistoryDepths); @@ -37,7 +37,7 @@ public class AdoscIndicatorTests { var indicator = new AdoscIndicator { - SlowPeriod = 40 + SlowPeriod = 40, }; Assert.Equal(40, indicator.SlowPeriod); diff --git a/quantower/IndicatorExtensions.cs b/quantower/IndicatorExtensions.cs index 4ca6e6b7..d63a3b1e 100644 --- a/quantower/IndicatorExtensions.cs +++ b/quantower/IndicatorExtensions.cs @@ -71,7 +71,7 @@ public static class IndicatorExtensions SourceType.HLC3 => item => (item[PriceType.High] + item[PriceType.Low] + item[PriceType.Close]) * 0.333333333333333333, SourceType.OHLC4 => item => (item[PriceType.Open] + item[PriceType.High] + item[PriceType.Low] + item[PriceType.Close]) * 0.25, SourceType.HLCC4 => item => (item[PriceType.High] + item[PriceType.Low] + item[PriceType.Close] + item[PriceType.Close]) * 0.25, - _ => item => item[PriceType.Close] + _ => item => item[PriceType.Close], }; } diff --git a/quantower/Mocks/ChartMocks.cs b/quantower/Mocks/ChartMocks.cs new file mode 100644 index 00000000..563e1649 --- /dev/null +++ b/quantower/Mocks/ChartMocks.cs @@ -0,0 +1,14 @@ +// Mock types for TradingPlatform.BusinessLayer.Chart to enable testing +// These are minimal implementations for unit testing purposes only + +namespace TradingPlatform.BusinessLayer.Chart; + +/// +/// Coordinates converter interface +/// +public interface IChartWindowCoordinatesConverter +{ + DateTime GetTime(int x); + double GetChartX(DateTime time); + double GetChartY(double value); +} diff --git a/quantower/Mocks/TradingPlatformMocks.cs b/quantower/Mocks/TradingPlatformMocks.cs index 41f56392..45de66b7 100644 --- a/quantower/Mocks/TradingPlatformMocks.cs +++ b/quantower/Mocks/TradingPlatformMocks.cs @@ -4,493 +4,479 @@ using System.Drawing; using TradingPlatform.BusinessLayer.Chart; -namespace TradingPlatform.BusinessLayer +namespace TradingPlatform.BusinessLayer; + +#region Enums + +/// +/// Specifies the style of indicator line. +/// +public enum LineStyle { - namespace Chart - { - /// - /// Coordinates converter interface - /// - public interface IChartWindowCoordinatesConverter - { - DateTime GetTime(int x); - double GetChartX(DateTime time); - double GetChartY(double value); - } - } - - #region Enums - - /// - /// Specifies the style of indicator line. - /// - public enum LineStyle - { - Solid, - Dash, - Dot, - DashDot, - Histogramm, - Points, - Columns, - StepLine - } - - /// - /// Price data types - /// - public enum PriceType - { - Open, - High, - Low, - Close, - Median, - Typical, - Weighted, - Bid, - BidSize, - Ask, - AskSize, - Last, - Volume, - Ticks, - AggressorFlag, - TickDirection, - BidTickDirection, - AskTickDirection, - OpenInterest, - Mark, - FundingRate, - QuoteAssetVolume - } - - /// - /// Seek origin for historical data - /// - public enum SeekOriginHistory - { - Begin, - End - } - - /// - /// Update reason for indicator - /// - public enum UpdateReason - { - Unknown, - HistoricalBar, - NewTick, - NewBar - } - - /// - /// Indicator line marker icon type - /// - public enum IndicatorLineMarkerIconType - { - None, - Point, - Circle, - Square, - Diamond, - Triangle, - TriangleDown, - Cross, - Plus, - Star, - Flag, - ArrowUp, - ArrowDown, - ArrowLeft, - ArrowRight - } - - #endregion - - #region Attributes - - /// - /// Attribute for input parameters - /// - [AttributeUsage(AttributeTargets.Property)] - public class InputParameterAttribute( - string name = "", - int sortIndex = 0, - double minimum = int.MinValue, - double maximum = int.MaxValue, - double increment = 0.01, - int decimalPlaces = 2, - object[]? variants = null) : Attribute - { - public string Name { get; } = name; - public int SortIndex { get; } = sortIndex; - public double Minimum { get; } = minimum; - public double Maximum { get; } = maximum; - public double Increment { get; } = increment; - public int DecimalPlaces { get; } = decimalPlaces; - public IComparable[]? Variants { get; } = variants?.Cast().ToArray(); - } - - #endregion - - #region History Item - - /// - /// History item interface - /// - public interface IHistoryItem - { - DateTime TimeLeft { get; } - long TicksLeft { get; set; } - long TicksRight { get; set; } - double this[PriceType priceType] { get; } - } - - /// - /// Mock history item for testing - /// - public class MockHistoryItem : IHistoryItem - { - public DateTime TimeLeft { get; set; } - public long TicksLeft { get; set; } - public long TicksRight { get; set; } - public double Open { get; set; } - public double High { get; set; } - public double Low { get; set; } - public double Close { get; set; } - public double Volume { get; set; } - - public double this[PriceType priceType] => priceType switch - { - PriceType.Open => Open, - PriceType.High => High, - PriceType.Low => Low, - PriceType.Close => Close, - PriceType.Volume => Volume, - PriceType.Median => (High + Low) / 2, - PriceType.Typical => (High + Low + Close) / 3, - PriceType.Weighted => (High + Low + Close + Close) / 4, - _ => Close - }; - } - - #endregion - - #region Historical Data - - /// - /// Mock historical data for testing - /// - public class HistoricalData - { - private readonly List _items = []; - - public int Count => _items.Count; - - public IHistoryItem this[int offset, SeekOriginHistory origin = SeekOriginHistory.End] - { - get - { - int index = origin == SeekOriginHistory.End - ? Count - 1 - offset - : offset; - return _items[index]; - } - } - - public DateTime Time(int offset = 0, SeekOriginHistory origin = SeekOriginHistory.End) - { - return this[offset, origin].TimeLeft; - } - - public long GetIndexByTime(long ticks) - { - for (int i = 0; i < _items.Count; i++) - { - if (_items[i].TicksLeft == ticks) - return Count - 1 - i; - } - return -1; - } - - public void Add(IHistoryItem item) - { - _items.Add(item); - } - - public void AddBar(DateTime time, double open, double high, double low, double close, double volume = 0) - { - _items.Add(new MockHistoryItem - { - TimeLeft = time, - TicksLeft = time.Ticks, - TicksRight = time.Ticks, - Open = open, - High = high, - Low = low, - Close = close, - Volume = volume - }); - } - - public void Clear() => _items.Clear(); - } - - #endregion - - #region Update Args - - /// - /// Update arguments for indicator - /// - public class UpdateArgs(UpdateReason reason) - { - public UpdateReason Reason { get; } = reason; - } - - #endregion - - #region Line Series - - /// - /// Base class for lines - /// - public class IndicatorLineMarker(Color color, IndicatorLineMarkerIconType icon = IndicatorLineMarkerIconType.None) - { - public Color Color { get; set; } = color; - public IndicatorLineMarkerIconType Icon { get; set; } = icon; - } - - public class Line(string name, Color color, int width, LineStyle style) - { - public string Name { get; set; } = name; - public Color Color { get; set; } = color; - public int Width { get; set; } = width; - public LineStyle Style { get; set; } = style; - public bool Visible { get; set; } = true; - } - - /// - /// Line series for indicator output - /// - public class LineSeries(string name, Color color, int width, LineStyle style) - : Line(name, color, width, style) - { - private readonly List _values = []; - private readonly List _markers = []; - - public int TimeShift { get; set; } - public int DrawBegin { get; set; } - public bool ShowLineMarker { get; set; } = true; - - public double this[int offset = 0, SeekOriginHistory origin = SeekOriginHistory.End] - { - get => GetValue(offset, origin); - set => SetValue(value, offset, origin); - } - - public double GetValue(int offset = 0, SeekOriginHistory origin = SeekOriginHistory.End) - { - if (_values.Count == 0) - return double.NaN; - - int index = origin == SeekOriginHistory.End - ? _values.Count - 1 - offset - : offset; - - if (index < 0 || index >= _values.Count) - return double.NaN; - - return _values[index]; - } - - public void SetValue(double value, int offset = 0, SeekOriginHistory origin = SeekOriginHistory.End) - { - EnsureCapacity(offset + 1); - int index = origin == SeekOriginHistory.End - ? _values.Count - 1 - offset - : offset; - _values[index] = value; - } - - public void SetMarker(int offset, Color color) - { - EnsureMarkerCapacity(offset + 1); - int index = _markers.Count - 1 - offset; - if (index >= 0 && index < _markers.Count) - _markers[index] = color; - } - - public void SetMarker(int offset, IndicatorLineMarker marker) - { - SetMarker(offset, marker.Color); - } - - internal void AddValue() - { - _values.Add(double.NaN); - _markers.Add(Color.Transparent); - } - - private void EnsureCapacity(int count) - { - while (_values.Count < count) - _values.Add(double.NaN); - } - - private void EnsureMarkerCapacity(int count) - { - while (_markers.Count < count) - _markers.Add(Color.Transparent); - } - - public int Count => _values.Count; - public IReadOnlyList Values => _values; - } - - #endregion - - #region Paint Chart Event Args - - /// - /// Paint chart event arguments - /// - public class PaintChartEventArgs(Graphics graphics, Rectangle clipRectangle, int windowIndex = 0) : EventArgs - { - public Graphics Graphics { get; } = graphics; - public Rectangle ClipRectangle { get; } = clipRectangle; - public int WindowIndex { get; } = windowIndex; - } - - #endregion - - #region Chart - - /// - /// Chart interface - /// - public interface IChart - { - ChartWindow MainWindow { get; } - IList Windows { get; } - int BarsWidth { get; } - } - - /// - /// Chart window - /// - public class ChartWindow - { - public Rectangle ClientRectangle { get; set; } - public IChartWindowCoordinatesConverter CoordinatesConverter { get; set; } = new MockCoordinatesConverter(); - } - - /// - /// Mock coordinates converter - /// - public class MockCoordinatesConverter : IChartWindowCoordinatesConverter - { - public DateTime GetTime(int x) => DateTime.UtcNow; - public double GetChartX(DateTime time) => 0; - public double GetChartY(double value) => 0; - } - - /// - /// Mock chart for testing - /// - public class MockChart : IChart - { - public ChartWindow MainWindow { get; } = new(); - public IList Windows { get; } = [new ChartWindow()]; - public int BarsWidth { get; set; } = 10; - } - - #endregion - - #region Indicator Base - - /// - /// Watchlist indicator interface - /// - public interface IWatchlistIndicator - { - int MinHistoryDepths { get; } - } - - /// - /// Base class for indicators - /// - public abstract class Indicator - { - private readonly List _lineSeries = []; - - public string Name { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - public virtual string ShortName => Name; - public virtual string SourceCodeLink => string.Empty; - - public bool SeparateWindow { get; set; } - public bool OnBackGround { get; set; } - - public HistoricalData HistoricalData { get; set; } = new(); - public IChart? CurrentChart { get; set; } - - public int Count => HistoricalData.Count; - - public IList LinesSeries => _lineSeries.ToArray(); - - protected void AddLineSeries(LineSeries series) - { - _lineSeries.Add(series); - } - - /// - /// Called when indicator is initialized - /// - protected virtual void OnInit() - { - // Intentionally empty - } - - /// - /// Called on each update - /// - protected virtual void OnUpdate(UpdateArgs args) - { - // Intentionally empty - } - - /// - /// Called for chart painting - /// - public virtual void OnPaintChart(PaintChartEventArgs args) - { - // Intentionally empty - } - - /// - /// Initialize the indicator (for testing) - /// - public void Initialize() - { - OnInit(); - } - - /// - /// Process an update (for testing) - /// - public void ProcessUpdate(UpdateArgs args) - { - // Ensure line series have capacity for new data - foreach (var series in _lineSeries) - { - series.AddValue(); - } - OnUpdate(args); - } - } - - #endregion + Solid, + Dash, + Dot, + DashDot, + Histogramm, + Points, + Columns, + StepLine, } + +/// +/// Price data types +/// +public enum PriceType +{ + Open, + High, + Low, + Close, + Median, + Typical, + Weighted, + Bid, + BidSize, + Ask, + AskSize, + Last, + Volume, + Ticks, + AggressorFlag, + TickDirection, + BidTickDirection, + AskTickDirection, + OpenInterest, + Mark, + FundingRate, + QuoteAssetVolume, +} + +/// +/// Seek origin for historical data +/// +public enum SeekOriginHistory +{ + Begin, + End, +} + +/// +/// Update reason for indicator +/// +public enum UpdateReason +{ + Unknown, + HistoricalBar, + NewTick, + NewBar, +} + +/// +/// Indicator line marker icon type +/// +public enum IndicatorLineMarkerIconType +{ + None, + Point, + Circle, + Square, + Diamond, + Triangle, + TriangleDown, + Cross, + Plus, + Star, + Flag, + ArrowUp, + ArrowDown, + ArrowLeft, + ArrowRight, +} + +#endregion + +#region Attributes + +/// +/// Attribute for input parameters +/// +[AttributeUsage(AttributeTargets.Property)] +public class InputParameterAttribute( + string name = "", + int sortIndex = 0, + double minimum = int.MinValue, + double maximum = int.MaxValue, + double increment = 0.01, + int decimalPlaces = 2, + object[]? variants = null) : Attribute +{ + public string Name { get; } = name; + public int SortIndex { get; } = sortIndex; + public double Minimum { get; } = minimum; + public double Maximum { get; } = maximum; + public double Increment { get; } = increment; + public int DecimalPlaces { get; } = decimalPlaces; + public IComparable[]? Variants { get; } = variants?.Cast().ToArray(); +} + +#endregion + +#region History Item + +/// +/// History item interface +/// +public interface IHistoryItem +{ + DateTime TimeLeft { get; } + long TicksLeft { get; set; } + long TicksRight { get; set; } + double this[PriceType priceType] { get; } +} + +/// +/// Mock history item for testing +/// +public class MockHistoryItem : IHistoryItem +{ + public DateTime TimeLeft { get; set; } + public long TicksLeft { get; set; } + public long TicksRight { get; set; } + public double Open { get; set; } + public double High { get; set; } + public double Low { get; set; } + public double Close { get; set; } + public double Volume { get; set; } + + public double this[PriceType priceType] => priceType switch + { + PriceType.Open => Open, + PriceType.High => High, + PriceType.Low => Low, + PriceType.Close => Close, + PriceType.Volume => Volume, + PriceType.Median => (High + Low) / 2, + PriceType.Typical => (High + Low + Close) / 3, + PriceType.Weighted => (High + Low + Close + Close) / 4, + _ => Close, + }; +} + +#endregion + +#region Historical Data + +/// +/// Mock historical data for testing +/// +public class HistoricalData +{ + private readonly List _items = []; + + public int Count => _items.Count; + + public IHistoryItem this[int offset, SeekOriginHistory origin = SeekOriginHistory.End] + { + get + { + int index = origin == SeekOriginHistory.End + ? Count - 1 - offset + : offset; + return _items[index]; + } + } + + public DateTime Time(int offset = 0, SeekOriginHistory origin = SeekOriginHistory.End) + { + return this[offset, origin].TimeLeft; + } + + public long GetIndexByTime(long ticks) + { + for (int i = 0; i < _items.Count; i++) + { + if (_items[i].TicksLeft == ticks) + return Count - 1 - i; + } + return -1; + } + + public void Add(IHistoryItem item) + { + _items.Add(item); + } + + public void AddBar(DateTime time, double open, double high, double low, double close, double volume = 0) + { + _items.Add(new MockHistoryItem + { + TimeLeft = time, + TicksLeft = time.Ticks, + TicksRight = time.Ticks, + Open = open, + High = high, + Low = low, + Close = close, + Volume = volume + }); + } + + public void Clear() => _items.Clear(); +} + +#endregion + +#region Update Args + +/// +/// Update arguments for indicator +/// +public class UpdateArgs(UpdateReason reason) +{ + public UpdateReason Reason { get; } = reason; +} + +#endregion + +#region Line Series + +/// +/// Base class for lines +/// +public class IndicatorLineMarker(Color color, IndicatorLineMarkerIconType icon = IndicatorLineMarkerIconType.None) +{ + public Color Color { get; set; } = color; + public IndicatorLineMarkerIconType Icon { get; set; } = icon; +} + +public class Line(string name, Color color, int width, LineStyle style) +{ + public string Name { get; set; } = name; + public Color Color { get; set; } = color; + public int Width { get; set; } = width; + public LineStyle Style { get; set; } = style; + public bool Visible { get; set; } = true; +} + +/// +/// Line series for indicator output +/// +public class LineSeries(string name, Color color, int width, LineStyle style) + : Line(name, color, width, style) +{ + private readonly List _values = []; + private readonly List _markers = []; + + public int TimeShift { get; set; } + public int DrawBegin { get; set; } + public bool ShowLineMarker { get; set; } = true; + + public double this[int offset = 0, SeekOriginHistory origin = SeekOriginHistory.End] + { + get => GetValue(offset, origin); + set => SetValue(value, offset, origin); + } + + public double GetValue(int offset = 0, SeekOriginHistory origin = SeekOriginHistory.End) + { + if (_values.Count == 0) + return double.NaN; + + int index = origin == SeekOriginHistory.End + ? _values.Count - 1 - offset + : offset; + + if (index < 0 || index >= _values.Count) + return double.NaN; + + return _values[index]; + } + + public void SetValue(double value, int offset = 0, SeekOriginHistory origin = SeekOriginHistory.End) + { + EnsureCapacity(offset + 1); + int index = origin == SeekOriginHistory.End + ? _values.Count - 1 - offset + : offset; + _values[index] = value; + } + + public void SetMarker(int offset, Color color) + { + EnsureMarkerCapacity(offset + 1); + int index = _markers.Count - 1 - offset; + if (index >= 0 && index < _markers.Count) + _markers[index] = color; + } + + public void SetMarker(int offset, IndicatorLineMarker marker) + { + SetMarker(offset, marker.Color); + } + + internal void AddValue() + { + _values.Add(double.NaN); + _markers.Add(Color.Transparent); + } + + private void EnsureCapacity(int count) + { + while (_values.Count < count) + _values.Add(double.NaN); + } + + private void EnsureMarkerCapacity(int count) + { + while (_markers.Count < count) + _markers.Add(Color.Transparent); + } + + public int Count => _values.Count; + public IReadOnlyList Values => _values; +} + +#endregion + +#region Paint Chart Event Args + +/// +/// Paint chart event arguments +/// +public class PaintChartEventArgs(Graphics graphics, Rectangle clipRectangle, int windowIndex = 0) : EventArgs +{ + public Graphics Graphics { get; } = graphics; + public Rectangle ClipRectangle { get; } = clipRectangle; + public int WindowIndex { get; } = windowIndex; +} + +#endregion + +#region Chart + +/// +/// Chart interface +/// +public interface IChart +{ + ChartWindow MainWindow { get; } + IList Windows { get; } + int BarsWidth { get; } +} + +/// +/// Chart window +/// +public class ChartWindow +{ + public Rectangle ClientRectangle { get; set; } + public IChartWindowCoordinatesConverter CoordinatesConverter { get; set; } = new MockCoordinatesConverter(); +} + +/// +/// Mock coordinates converter +/// +public class MockCoordinatesConverter : IChartWindowCoordinatesConverter +{ + public DateTime GetTime(int x) => DateTime.UtcNow; + public double GetChartX(DateTime time) => 0; + public double GetChartY(double value) => 0; +} + +/// +/// Mock chart for testing +/// +public class MockChart : IChart +{ + public ChartWindow MainWindow { get; } = new(); + public IList Windows { get; } = [new ChartWindow()]; + public int BarsWidth { get; set; } = 10; +} + +#endregion + +#region Indicator Base + +/// +/// Watchlist indicator interface +/// +public interface IWatchlistIndicator +{ + int MinHistoryDepths { get; } +} + +/// +/// Base class for indicators +/// +public abstract class Indicator +{ + private readonly List _lineSeries = []; + + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public virtual string ShortName => Name; + public virtual string SourceCodeLink => string.Empty; + + public bool SeparateWindow { get; set; } + public bool OnBackGround { get; set; } + + public HistoricalData HistoricalData { get; set; } = new(); + public IChart? CurrentChart { get; set; } + + public int Count => HistoricalData.Count; + + public IList LinesSeries => _lineSeries.ToArray(); + + protected void AddLineSeries(LineSeries series) + { + _lineSeries.Add(series); + } + + /// + /// Called when indicator is initialized + /// + protected virtual void OnInit() + { + // Intentionally empty + } + + /// + /// Called on each update + /// + protected virtual void OnUpdate(UpdateArgs args) + { + // Intentionally empty + } + + /// + /// Called for chart painting + /// + public virtual void OnPaintChart(PaintChartEventArgs args) + { + // Intentionally empty + } + + /// + /// Initialize the indicator (for testing) + /// + public void Initialize() + { + OnInit(); + } + + /// + /// Process an update (for testing) + /// + public void ProcessUpdate(UpdateArgs args) + { + // Ensure line series have capacity for new data + foreach (var series in _lineSeries) + { + series.AddValue(); + } + OnUpdate(args); + } +} + +#endregion