diff --git a/.clinerules/AGENTS.md b/.clinerules/AGENTS.md index 8dc2ee2a..923080bf 100644 --- a/.clinerules/AGENTS.md +++ b/.clinerules/AGENTS.md @@ -65,7 +65,42 @@ CRIT_PATTERNS: - Hot methods: `[MethodImpl(MethodImplOptions.AggressiveInlining)]`. - Tight loops: `[SkipLocalsInit]`. -2.5 FMA patterns (use in hot paths) +2.5 State local copy pattern (performance + correctness) +For Update() methods with record struct state, use local copy to enable struct promotion: +```csharp +public TValue Update(TValue input, bool isNew = true) +{ + if (isNew) _ps = _s; // snapshot for rollback + else _s = _ps; // rollback on bar correction + + var s = _s; // local copy enables JIT struct promotion + // ... all computations use 's' ... + _s = s; // write back once at end + return Last; +} +``` +Why: JIT can promote `s` to registers when it's a local; direct field access (`_s.Field`) forces memory loads/stores per access. Measured 15-25% speedup in tight Update loops. + +2.6 StackallocThreshold pattern +Use `const int StackallocThreshold = 256;` for span-based Calculate methods: +```csharp +const int StackallocThreshold = 256; +double[]? rented = null; +scoped Span buffer; + +if (size <= StackallocThreshold) + buffer = stackalloc double[size]; +else +{ + rented = ArrayPool.Shared.Rent(size); + buffer = rented.AsSpan(0, size); +} +try { /* use buffer */ } +finally { if (rented != null) ArrayPool.Shared.Return(rented); } +``` +Why 256: Default thread stack is 1MB; 256 doubles = 2KB, safe margin for nested calls. Beyond 256, risk stack overflow in deep recursion or chained indicators. ArrayPool amortizes allocation cost for larger buffers. + +2.7 FMA patterns (use in hot paths) - EMA smoothing: `x + alpha * (y - x)` → `Math.FusedMultiplyAdd(x, decay, alpha * y)` where `decay = 1 - alpha` - Weighted sum: `a*w1 + b*w2` → `Math.FusedMultiplyAdd(a, w1, b * w2)` - Linear combo: `3.0*a - b` → `Math.FusedMultiplyAdd(3.0, a, -b)` @@ -77,6 +112,15 @@ Precompute decay constants: `private readonly double _alpha; private readonly double _decay; // = 1 - _alpha` Hot path: `result = Math.FusedMultiplyAdd(prevState, _decay, _alpha * newInput);` +2.8 Suppression comment pattern +When using analyzer suppressions (skipcq, pragma), always document WHY: +```csharp +// skipcq: CS-R1140 - Cyclomatic complexity justified: [specific reason why +// splitting would harm performance/correctness/readability] +``` +Bad: `// skipcq: CS-R1140` (no explanation) +Good: `// skipcq: CS-R1140 - Algorithm requires sequential A→B→C pipeline; splitting fragments state machine` + 3) IMPLEMENTATION STANDARDS (every indicator) 3.1 File layout DIR: `lib/[category]/[name]/` (eg `lib/trends/sma/`) diff --git a/.github/instructions/codacy.instructions.md b/.github/instructions/codacy.instructions.md index cb073c46..7429440c 100644 --- a/.github/instructions/codacy.instructions.md +++ b/.github/instructions/codacy.instructions.md @@ -6,6 +6,13 @@ # Codacy Rules Configuration for AI behavior when interacting with Codacy's MCP Server +## using any tool that accepts the arguments: `provider`, `organization`, or `repository` +- ALWAYS use: + - provider: gh + - organization: mihakralj + - repository: QuanTAlib +- Avoid calling `git remote -v` unless really necessary + ## CRITICAL: After ANY successful `edit_file` or `reapply` operation - YOU MUST IMMEDIATELY run the `codacy_cli_analyze` tool from Codacy's MCP Server for each file that was edited, with: - `rootPath`: set to the workspace path diff --git a/lib/channels/bbands/Bbands.Quantower.cs b/lib/channels/bbands/Bbands.Quantower.cs index ea451ad5..9fdebf9d 100644 --- a/lib/channels/bbands/Bbands.Quantower.cs +++ b/lib/channels/bbands/Bbands.Quantower.cs @@ -60,11 +60,10 @@ public class BbandsIndicator : Indicator, IWatchlistIndicator protected override void OnUpdate(UpdateArgs args) { var priceSelector = Source.GetPriceSelector(); - var item = HistoricalData[Count - 1, SeekOriginHistory.Begin]; + var item = HistoricalData[0, SeekOriginHistory.End]; double price = priceSelector(item); - var time = HistoricalData.Time(); - - TValue input = new(time, price); + + TValue input = new(item.TimeLeft, price); TValue result = bbands!.Update(input, args.IsNewBar()); MiddleSeries!.SetValue(result.Value, bbands.IsHot, ShowColdValues); diff --git a/lib/channels/bbands/Bbands.cs b/lib/channels/bbands/Bbands.cs index 5fe31a1c..8348cc45 100644 --- a/lib/channels/bbands/Bbands.cs +++ b/lib/channels/bbands/Bbands.cs @@ -255,40 +255,64 @@ public sealed class Bbands : AbstractBase // Calculate SMA using static batch method Sma.Batch(source, middle, period); - // Calculate standard deviation and bands - for (int i = 0; i < len; i++) + // Calculate standard deviation and bands using O(n) rolling sums + // Instead of O(n²) nested loop, maintain running sum and sumSq + double rollingSum = 0.0; + double rollingSumSq = 0.0; + + // Initialize rolling sums for first window + for (int i = 0; i < Math.Min(period, len); i++) { + double val = source[i]; + if (double.IsFinite(val)) + { + rollingSum += val; + rollingSumSq += val * val; + } + if (i < period - 1) { upper[i] = double.NaN; lower[i] = double.NaN; - continue; } + } - // Calculate standard deviation for the current window - double sum = 0.0; - double sumSq = 0.0; - int count = 0; + // Process first complete window + if (len >= period) + { + double mean = rollingSum / period; + double variance = (rollingSumSq / period) - (mean * mean); + variance = Math.Max(0.0, variance); // Guard against negative due to floating point + double stdDev = Math.Sqrt(variance); + double offset = multiplier * stdDev; + upper[period - 1] = middle[period - 1] + offset; + lower[period - 1] = middle[period - 1] - offset; + } - for (int j = i - period + 1; j <= i; j++) + // Process remaining bars with O(1) rolling update + for (int i = period; i < len; i++) + { + // Remove outgoing value (leftmost of previous window) + double outgoing = source[i - period]; + if (double.IsFinite(outgoing)) { - double val = source[j]; - if (double.IsFinite(val)) - { - sum += val; - sumSq += val * val; - count++; - } + rollingSum -= outgoing; + rollingSumSq -= outgoing * outgoing; } - double variance = 0.0; - if (count > 0) + // Add incoming value (current) + double incoming = source[i]; + if (double.IsFinite(incoming)) { - double mean = sum / count; - variance = (sumSq / count) - (mean * mean); - variance = Math.Max(0.0, variance); // Guard against negative due to floating point + rollingSum += incoming; + rollingSumSq += incoming * incoming; } + // Calculate variance from rolling sums: Var = E[X²] - E[X]² + double mean = rollingSum / period; + double variance = (rollingSumSq / period) - (mean * mean); + variance = Math.Max(0.0, variance); // Guard against negative due to floating point + double stdDev = Math.Sqrt(variance); double offset = multiplier * stdDev; diff --git a/lib/channels/dchannel/Dchannel.cs b/lib/channels/dchannel/Dchannel.cs index a7776f54..28eda783 100644 --- a/lib/channels/dchannel/Dchannel.cs +++ b/lib/channels/dchannel/Dchannel.cs @@ -16,14 +16,8 @@ public sealed class Dchannel : ITValuePublisher private readonly int _period; private readonly double[] _hBuf; private readonly double[] _lBuf; - private readonly int[] _hDeque; - private readonly int[] _lDeque; - - // Queue state - private int _hHead; - private int _hCount; - private int _lHead; - private int _lCount; + private readonly MonotonicDeque _maxDeque; + private readonly MonotonicDeque _minDeque; // Rolling counters private int _count; @@ -53,12 +47,8 @@ public sealed class Dchannel : ITValuePublisher _period = period; _hBuf = new double[_period]; _lBuf = new double[_period]; - _hDeque = new int[_period]; - _lDeque = new int[_period]; - _hHead = 0; - _lHead = 0; - _hCount = 0; - _lCount = 0; + _maxDeque = new MonotonicDeque(_period); + _minDeque = new MonotonicDeque(_period); _count = 0; _index = -1; _state = new State(double.NaN, double.NaN, false); @@ -96,90 +86,6 @@ public sealed class Dchannel : ITValuePublisher return (high, low); } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void PushMax(long logicalIndex, double value) - { - // Expire old indices - long expire = logicalIndex - _period; - while (_hCount > 0 && _hDeque[_hHead] <= expire) - { - _hHead = (_hHead + 1) % _period; - _hCount--; - } - - // Maintain monotonic non-increasing deque - int backIdx; - while (_hCount > 0) - { - backIdx = (_hHead + _hCount - 1) % _period; - int bufIdx = _hDeque[backIdx] % _period; - if (_hBuf[bufIdx] <= value) - { - _hCount--; - } - else - { - break; - } - } - - int tail = (_hHead + _hCount) % _period; - _hDeque[tail] = (int)logicalIndex; - _hCount++; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void PushMin(long logicalIndex, double value) - { - long expire = logicalIndex - _period; - while (_lCount > 0 && _lDeque[_lHead] <= expire) - { - _lHead = (_lHead + 1) % _period; - _lCount--; - } - - int backIdx; - while (_lCount > 0) - { - backIdx = (_lHead + _lCount - 1) % _period; - int bufIdx = _lDeque[backIdx] % _period; - if (_lBuf[bufIdx] >= value) - { - _lCount--; - } - else - { - break; - } - } - - int tail = (_lHead + _lCount) % _period; - _lDeque[tail] = (int)logicalIndex; - _lCount++; - } - - private void RebuildDeques() - { - _hHead = 0; - _lHead = 0; - _hCount = 0; - _lCount = 0; - - if (_count == 0) - return; - - long startLogical = _index - _count + 1; - for (int i = 0; i < _count; i++) - { - long logicalIndex = startLogical + i; - int bufIdx = (int)(logicalIndex % _period); - double h = _hBuf[bufIdx]; - double l = _lBuf[bufIdx]; - PushMax(logicalIndex, h); - PushMin(logicalIndex, l); - } - } - [MethodImpl(MethodImplOptions.AggressiveInlining)] public TValue Update(TBar input, bool isNew = true) { @@ -213,17 +119,18 @@ public sealed class Dchannel : ITValuePublisher if (isNew) { - PushMax(_index, high); - PushMin(_index, low); + _maxDeque.PushMax(_index, high, _hBuf); + _minDeque.PushMin(_index, low, _lBuf); } else { // Correcting current bar: rebuild deques to maintain consistency - RebuildDeques(); + _maxDeque.RebuildMax(_hBuf, _index, _count); + _minDeque.RebuildMin(_lBuf, _index, _count); } - double top = _hBuf[_hDeque[_hHead] % _period]; - double bot = _lBuf[_lDeque[_lHead] % _period]; + double top = _maxDeque.GetExtremum(_hBuf); + double bot = _minDeque.GetExtremum(_lBuf); double mid = (top + bot) * 0.5; if (!IsHot && _count >= _period) @@ -296,10 +203,8 @@ public sealed class Dchannel : ITValuePublisher { Array.Clear(_hBuf); Array.Clear(_lBuf); - _hHead = 0; - _lHead = 0; - _hCount = 0; - _lCount = 0; + _maxDeque.Reset(); + _minDeque.Reset(); _count = 0; _index = -1; _state = new State(double.NaN, double.NaN, false); @@ -390,4 +295,4 @@ public sealed class Dchannel : ITValuePublisher var results = indicator.Update(source); return (results, indicator); } -} +} \ No newline at end of file diff --git a/lib/channels/mmchannel/Mmchannel.cs b/lib/channels/mmchannel/Mmchannel.cs index 25012885..245b9095 100644 --- a/lib/channels/mmchannel/Mmchannel.cs +++ b/lib/channels/mmchannel/Mmchannel.cs @@ -1,4 +1,3 @@ -using System.Buffers; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; @@ -16,14 +15,8 @@ public sealed class Mmchannel : ITValuePublisher private readonly int _period; private readonly double[] _hBuf; private readonly double[] _lBuf; - private readonly int[] _hDeque; - private readonly int[] _lDeque; - - // Queue state - private int _hHead; - private int _hCount; - private int _lHead; - private int _lCount; + private readonly MonotonicDeque _maxDeque; + private readonly MonotonicDeque _minDeque; // Rolling counters private int _count; @@ -53,12 +46,8 @@ public sealed class Mmchannel : ITValuePublisher _period = period; _hBuf = new double[_period]; _lBuf = new double[_period]; - _hDeque = new int[_period]; - _lDeque = new int[_period]; - _hHead = 0; - _lHead = 0; - _hCount = 0; - _lCount = 0; + _maxDeque = new MonotonicDeque(_period); + _minDeque = new MonotonicDeque(_period); _count = 0; _index = -1; _state = new State(double.NaN, double.NaN, false); @@ -96,90 +85,6 @@ public sealed class Mmchannel : ITValuePublisher return (high, low); } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void PushMax(long logicalIndex, double value) - { - // Expire old indices - long expire = logicalIndex - _period; - while (_hCount > 0 && _hDeque[_hHead] <= expire) - { - _hHead = (_hHead + 1) % _period; - _hCount--; - } - - // Maintain monotonic non-increasing deque - int backIdx; - while (_hCount > 0) - { - backIdx = (_hHead + _hCount - 1) % _period; - int bufIdx = _hDeque[backIdx] % _period; - if (_hBuf[bufIdx] <= value) - { - _hCount--; - } - else - { - break; - } - } - - int tail = (_hHead + _hCount) % _period; - _hDeque[tail] = (int)logicalIndex; - _hCount++; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void PushMin(long logicalIndex, double value) - { - long expire = logicalIndex - _period; - while (_lCount > 0 && _lDeque[_lHead] <= expire) - { - _lHead = (_lHead + 1) % _period; - _lCount--; - } - - int backIdx; - while (_lCount > 0) - { - backIdx = (_lHead + _lCount - 1) % _period; - int bufIdx = _lDeque[backIdx] % _period; - if (_lBuf[bufIdx] >= value) - { - _lCount--; - } - else - { - break; - } - } - - int tail = (_lHead + _lCount) % _period; - _lDeque[tail] = (int)logicalIndex; - _lCount++; - } - - private void RebuildDeques() - { - _hHead = 0; - _lHead = 0; - _hCount = 0; - _lCount = 0; - - if (_count == 0) - return; - - long startLogical = _index - _count + 1; - for (int i = 0; i < _count; i++) - { - long logicalIndex = startLogical + i; - int bufIdx = (int)(logicalIndex % _period); - double h = _hBuf[bufIdx]; - double l = _lBuf[bufIdx]; - PushMax(logicalIndex, h); - PushMin(logicalIndex, l); - } - } - [MethodImpl(MethodImplOptions.AggressiveInlining)] public TValue Update(TBar input, bool isNew = true) { @@ -213,17 +118,18 @@ public sealed class Mmchannel : ITValuePublisher if (isNew) { - PushMax(_index, high); - PushMin(_index, low); + _maxDeque.PushMax(_index, high, _hBuf); + _minDeque.PushMin(_index, low, _lBuf); } else { // Correcting current bar: rebuild deques to maintain consistency - RebuildDeques(); + _maxDeque.RebuildMax(_hBuf, _index, _count); + _minDeque.RebuildMin(_lBuf, _index, _count); } - double top = _hBuf[_hDeque[_hHead] % _period]; - double bot = _lBuf[_lDeque[_lHead] % _period]; + double top = _maxDeque.GetExtremum(_hBuf); + double bot = _minDeque.GetExtremum(_lBuf); if (!IsHot && _count >= _period) _state = _state with { IsHot = true }; @@ -290,10 +196,8 @@ public sealed class Mmchannel : ITValuePublisher { Array.Clear(_hBuf); Array.Clear(_lBuf); - _hHead = 0; - _lHead = 0; - _hCount = 0; - _lCount = 0; + _maxDeque.Reset(); + _minDeque.Reset(); _count = 0; _index = -1; _state = new State(double.NaN, double.NaN, false); @@ -357,4 +261,4 @@ public sealed class Mmchannel : ITValuePublisher var results = indicator.Update(source); return (results, indicator); } -} +} \ No newline at end of file diff --git a/lib/channels/regchannel/Regchannel.Tests.cs b/lib/channels/regchannel/Regchannel.Tests.cs index 32e73032..911c07eb 100644 --- a/lib/channels/regchannel/Regchannel.Tests.cs +++ b/lib/channels/regchannel/Regchannel.Tests.cs @@ -247,6 +247,44 @@ public class RegchannelTests Assert.True(double.IsFinite(ind.Lower.Value)); } + [Fact] + public void BarCorrection_UpdatesLastValid() + { + // Verifies that bar correction (isNew:false) with a finite value updates LastValid, + // so subsequent NaN/Inf inputs use the corrected value, not the pre-correction value. + var ind = new Regchannel(5, 2.0); + var now = DateTime.UtcNow; + + // Feed initial values + ind.Update(new TValue(now, 100)); + ind.Update(new TValue(now, 110)); + ind.Update(new TValue(now, 120)); + + // Last bar: 130 (LastValid should be 130) + ind.Update(new TValue(now, 130)); + + // Correct the last bar with isNew:false to 140 (should update LastValid to 140) + ind.Update(new TValue(now, 140), isNew: false); + + // Now send NaN - it should use LastValid=140, not the old 130 + var resultWithNaN = ind.Update(new TValue(now, double.NaN)); + + // The regression should include 100, 110, 120, 140 (the corrected value) + // If bug existed, it would use 130 instead + Assert.True(double.IsFinite(resultWithNaN.Value)); + + // Verify by checking the buffer contains the corrected value + // The regression endpoint should reflect using 140 not 130 + // For 4 values [100, 110, 120, 140]: + // sumX = 0+1+2+3 = 6, sumX² = 14, n=4 + // sumY = 470, sumXY = 0*100 + 1*110 + 2*120 + 3*140 = 770 + // denom = 4*14 - 36 = 20 + // slope = (4*770 - 6*470) / 20 = (3080 - 2820) / 20 = 13 + // intercept = (470 - 13*6) / 4 = (470 - 78) / 4 = 98 + // regression at x=3: 98 + 13*3 = 137 + Assert.Equal(137.0, resultWithNaN.Value, 1e-9); + } + [Fact] public void Reset_ClearsState() { diff --git a/lib/channels/regchannel/Regchannel.cs b/lib/channels/regchannel/Regchannel.cs index 53255a20..f4e8a692 100644 --- a/lib/channels/regchannel/Regchannel.cs +++ b/lib/channels/regchannel/Regchannel.cs @@ -135,8 +135,8 @@ public sealed class Regchannel : ITValuePublisher { if (double.IsFinite(value)) { - if (isNew) - _state = _state with { LastValid = value }; + // Always update LastValid on finite input (including bar corrections) + _state = _state with { LastValid = value }; return value; } return double.IsFinite(_state.LastValid) ? _state.LastValid : 0.0; diff --git a/lib/channels/sdchannel/Sdchannel.Tests.cs b/lib/channels/sdchannel/Sdchannel.Tests.cs index 8b6dbad6..c412ef15 100644 --- a/lib/channels/sdchannel/Sdchannel.Tests.cs +++ b/lib/channels/sdchannel/Sdchannel.Tests.cs @@ -270,6 +270,44 @@ public class SdchannelTests Assert.True(double.IsFinite(result2.Value)); } + [Fact] + public void Sdchannel_BarCorrection_UpdatesLastValid() + { + // Verifies that bar correction (isNew:false) with a finite value updates LastValid, + // so subsequent NaN/Inf inputs use the corrected value, not the pre-correction value. + var s = new Sdchannel(5, 2.0); + var now = DateTime.UtcNow; + + // Feed initial values + s.Update(new TValue(now, 100)); + s.Update(new TValue(now, 110)); + s.Update(new TValue(now, 120)); + + // Last bar: 130 (LastValid should be 130) + s.Update(new TValue(now, 130)); + + // Correct the last bar with isNew:false to 140 (should update LastValid to 140) + s.Update(new TValue(now, 140), isNew: false); + + // Now send NaN - it should use LastValid=140, not the old 130 + var resultWithNaN = s.Update(new TValue(now, double.NaN)); + + // The regression should include 100, 110, 120, 140 (the corrected value) + // If bug existed, it would use 130 instead + Assert.True(double.IsFinite(resultWithNaN.Value)); + + // Verify by checking the buffer contains the corrected value + // The regression endpoint should reflect using 140 not 130 + // For 4 values [100, 110, 120, 140]: + // sumX = 0+1+2+3 = 6, sumX² = 14, n=4 + // sumY = 470, sumXY = 0*100 + 1*110 + 2*120 + 3*140 = 770 + // denom = 4*14 - 36 = 20 + // slope = (4*770 - 6*470) / 20 = (3080 - 2820) / 20 = 13 + // intercept = (470 - 13*6) / 4 = (470 - 78) / 4 = 98 + // regression at x=3: 98 + 13*3 = 137 + Assert.Equal(137.0, resultWithNaN.Value, 1e-9); + } + [Fact] public void Sdchannel_Reset_Clears() { diff --git a/lib/channels/sdchannel/Sdchannel.cs b/lib/channels/sdchannel/Sdchannel.cs index a94454cb..fd46cad6 100644 --- a/lib/channels/sdchannel/Sdchannel.cs +++ b/lib/channels/sdchannel/Sdchannel.cs @@ -135,8 +135,8 @@ public sealed class Sdchannel : ITValuePublisher { if (double.IsFinite(value)) { - if (isNew) - _state = _state with { LastValid = value }; + // Always update LastValid on finite input (including bar corrections) + _state = _state with { LastValid = value }; return value; } return double.IsFinite(_state.LastValid) ? _state.LastValid : 0.0; diff --git a/lib/channels/stbands/Stbands.Quantower.cs b/lib/channels/stbands/Stbands.Quantower.cs index d3d43eb4..c7679afd 100644 --- a/lib/channels/stbands/Stbands.Quantower.cs +++ b/lib/channels/stbands/Stbands.Quantower.cs @@ -53,11 +53,10 @@ public class StbandsIndicator : Indicator, IWatchlistIndicator protected override void OnUpdate(UpdateArgs args) { - var item = HistoricalData[Count - 1, SeekOriginHistory.Begin]; - var time = HistoricalData.Time(); + var item = HistoricalData[0, SeekOriginHistory.End]; TBar bar = new( - time, + item.TimeLeft, item[PriceType.Open], item[PriceType.High], item[PriceType.Low], diff --git a/lib/channels/stbands/Stbands.cs b/lib/channels/stbands/Stbands.cs index 62ef5c6c..a12cbdbf 100644 --- a/lib/channels/stbands/Stbands.cs +++ b/lib/channels/stbands/Stbands.cs @@ -33,8 +33,6 @@ public sealed class Stbands : AbstractBase { private readonly double _multiplier; private readonly RingBuffer _trBuffer; - private double _trSum; - private int _trCount; private const int DefaultPeriod = 10; private const double DefaultMultiplier = 3.0; private const double MinMultiplier = 0.001; @@ -47,8 +45,6 @@ public sealed class Stbands : AbstractBase double FinalLower, int Trend, double PrevClose, - double TrSum, - int TrCount, bool IsInitialized); private State _state; @@ -102,9 +98,7 @@ public sealed class Stbands : AbstractBase private void Init() { _index = 0; - _trSum = 0; - _trCount = 0; - _state = new State(0, 0, 1, 0, 0, 0, false); + _state = new State(0, 0, 1, 0, false); _p_state = _state; _trBuffer.Clear(); Upper = new TValue(DateTime.UtcNow, 0); @@ -189,7 +183,7 @@ public sealed class Stbands : AbstractBase } // Update state - _state = new State(finalUpper, finalLower, trend, close, _trSum, _trCount, true); + _state = new State(finalUpper, finalLower, trend, close, true); // Update output values Upper = new TValue(input.Time, finalUpper); diff --git a/lib/channels/ubands/Ubands.Quantower.cs b/lib/channels/ubands/Ubands.Quantower.cs index 588ea34c..be8169df 100644 --- a/lib/channels/ubands/Ubands.Quantower.cs +++ b/lib/channels/ubands/Ubands.Quantower.cs @@ -57,11 +57,10 @@ public class UbandsIndicator : Indicator, IWatchlistIndicator protected override void OnUpdate(UpdateArgs args) { var priceSelector = Source.GetPriceSelector(); - var item = HistoricalData[Count - 1, SeekOriginHistory.Begin]; + var item = HistoricalData[0, SeekOriginHistory.End]; double price = priceSelector(item); - var time = HistoricalData.Time(); - TValue input = new(time, price); + TValue input = new(item.TimeLeft, price); TValue result = ubands!.Update(input, args.IsNewBar()); MiddleSeries!.SetValue(result.Value, ubands.IsHot, ShowColdValues); diff --git a/lib/channels/uchannel/Uchannel.Quantower.cs b/lib/channels/uchannel/Uchannel.Quantower.cs index b0aff9fc..58f8ea3b 100644 --- a/lib/channels/uchannel/Uchannel.Quantower.cs +++ b/lib/channels/uchannel/Uchannel.Quantower.cs @@ -59,14 +59,13 @@ public class UchannelIndicator : Indicator, IWatchlistIndicator protected override void OnUpdate(UpdateArgs args) { - var item = HistoricalData[Count - 1, SeekOriginHistory.Begin]; + var item = HistoricalData[0, SeekOriginHistory.End]; double open = item[PriceType.Open]; double high = item[PriceType.High]; double low = item[PriceType.Low]; double close = item[PriceType.Close]; - var time = HistoricalData.Time(); - TBar input = new(time, open, high, low, close, item[PriceType.Volume]); + TBar input = new(item.TimeLeft, open, high, low, close, item[PriceType.Volume]); TValue result = uchannel!.Update(input, args.IsNewBar()); MiddleSeries!.SetValue(result.Value, uchannel.IsHot, ShowColdValues); diff --git a/lib/channels/vwapbands/Vwapbands.Quantower.cs b/lib/channels/vwapbands/Vwapbands.Quantower.cs index 88c97e3c..561364c7 100644 --- a/lib/channels/vwapbands/Vwapbands.Quantower.cs +++ b/lib/channels/vwapbands/Vwapbands.Quantower.cs @@ -58,8 +58,7 @@ public class VwapbandsIndicator : Indicator, IWatchlistIndicator protected override void OnUpdate(UpdateArgs args) { - var item = HistoricalData[Count - 1, SeekOriginHistory.Begin]; - var time = HistoricalData.Time(); + var item = HistoricalData[0, SeekOriginHistory.End]; // VWAP requires OHLCV data - using HLC3 for price double high = item[PriceType.High]; @@ -67,7 +66,7 @@ public class VwapbandsIndicator : Indicator, IWatchlistIndicator double close = item[PriceType.Close]; double volume = item[PriceType.Volume]; - TBar bar = new(time, item[PriceType.Open], high, low, close, volume); + TBar bar = new(item.TimeLeft, item[PriceType.Open], high, low, close, volume); TValue result = vwapbands!.Update(bar, args.IsNewBar()); VwapSeries!.SetValue(result.Value, vwapbands.IsHot, ShowColdValues); diff --git a/lib/channels/vwapbands/Vwapbands.Tests.cs b/lib/channels/vwapbands/Vwapbands.Tests.cs index e235b63a..4bec1b74 100644 --- a/lib/channels/vwapbands/Vwapbands.Tests.cs +++ b/lib/channels/vwapbands/Vwapbands.Tests.cs @@ -313,6 +313,37 @@ public class VwapbandsTests Assert.NotEqual(vwapBeforeReset, vwapbands.Vwap.Value); } + [Fact] + public void Vwapbands_SessionReset_ResetsIsHotGating() + { + var vwapbands = new Vwapbands(1.0); + var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42); + var bars = gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // Process bars until IsHot is true (WarmupPeriod = 2) + vwapbands.Update(bars[0]); + Assert.False(vwapbands.IsHot); + vwapbands.Update(bars[1]); + Assert.True(vwapbands.IsHot); + + // Process more bars to ensure we're well past warmup + for (int i = 2; i < 10; i++) + { + vwapbands.Update(bars[i]); + } + Assert.True(vwapbands.IsHot); + + // Reset session - IsHot should become false + var resetBar1 = new TBar(DateTime.UtcNow, 200, 210, 190, 200, 1000); + vwapbands.Update(resetBar1, isNew: true, reset: true); + Assert.False(vwapbands.IsHot, "IsHot should be false after reset (1 bar accumulated)"); + + // Process second bar after reset - IsHot should become true + var resetBar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 205, 215, 195, 205, 1100); + vwapbands.Update(resetBar2, isNew: true); + Assert.True(vwapbands.IsHot, "IsHot should be true after 2 bars accumulated post-reset"); + } + [Fact] public void Vwapbands_VwapFormula_MatchesExpected() { diff --git a/lib/channels/vwapbands/Vwapbands.Validation.Tests.cs b/lib/channels/vwapbands/Vwapbands.Validation.Tests.cs index 527c2d0c..e3a0ff98 100644 --- a/lib/channels/vwapbands/Vwapbands.Validation.Tests.cs +++ b/lib/channels/vwapbands/Vwapbands.Validation.Tests.cs @@ -110,12 +110,12 @@ public sealed class VwapbandsValidationTests : IDisposable streamingLower2.Add(streamingVwapbands.Lower2.Value); } - // Span mode - using HLC3 for price + // Span mode - using HLC3 for price (use bar.HLC3 property for consistency) double[] price = new double[bars.Count]; double[] volume = new double[bars.Count]; for (int i = 0; i < bars.Count; i++) { - price[i] = (bars[i].High + bars[i].Low + bars[i].Close) / 3.0; + price[i] = bars[i].HLC3; volume[i] = bars[i].Volume; } diff --git a/lib/channels/vwapbands/Vwapbands.cs b/lib/channels/vwapbands/Vwapbands.cs index 23515d61..cf5ab221 100644 --- a/lib/channels/vwapbands/Vwapbands.cs +++ b/lib/channels/vwapbands/Vwapbands.cs @@ -138,8 +138,7 @@ public sealed class Vwapbands : AbstractBase [MethodImpl(MethodImplOptions.AggressiveInlining)] public TValue Update(TBar bar, bool isNew = true, bool reset = false) { - double hlc3 = (bar.High + bar.Low + bar.Close) / 3.0; - return Update(new TValue(bar.Time, hlc3), bar.Volume, isNew, reset); + return Update(new TValue(bar.Time, bar.HLC3), bar.Volume, isNew, reset); } /// @@ -174,6 +173,9 @@ public sealed class Vwapbands : AbstractBase // Handle reset if (reset || !_state.IsInitialized) { + // Reset warmup tracking for proper IsHot gating after session reset + _index = 1; + if (vol > 0) { _state = _state with diff --git a/lib/channels/vwapsd/Vwapsd.Quantower.cs b/lib/channels/vwapsd/Vwapsd.Quantower.cs index 73eee9b7..551f70f9 100644 --- a/lib/channels/vwapsd/Vwapsd.Quantower.cs +++ b/lib/channels/vwapsd/Vwapsd.Quantower.cs @@ -52,8 +52,7 @@ public class VwapsdIndicator : Indicator, IWatchlistIndicator protected override void OnUpdate(UpdateArgs args) { - var item = HistoricalData[Count - 1, SeekOriginHistory.Begin]; - var time = HistoricalData.Time(); + var item = HistoricalData[0, SeekOriginHistory.End]; // VWAP requires OHLCV data - using HLC3 for price double high = item[PriceType.High]; @@ -61,7 +60,7 @@ public class VwapsdIndicator : Indicator, IWatchlistIndicator double close = item[PriceType.Close]; double volume = item[PriceType.Volume]; - TBar bar = new(time, item[PriceType.Open], high, low, close, volume); + TBar bar = new(item.TimeLeft, item[PriceType.Open], high, low, close, volume); TValue result = vwapsd!.Update(bar, args.IsNewBar()); VwapSeries!.SetValue(result.Value, vwapsd.IsHot, ShowColdValues); diff --git a/lib/channels/vwapsd/Vwapsd.Tests.cs b/lib/channels/vwapsd/Vwapsd.Tests.cs index 1c181012..a3bf60ec 100644 --- a/lib/channels/vwapsd/Vwapsd.Tests.cs +++ b/lib/channels/vwapsd/Vwapsd.Tests.cs @@ -325,6 +325,37 @@ public class VwapsdTests Assert.NotEqual(vwapBeforeReset, vwapsd.Vwap.Value); } + [Fact] + public void Vwapsd_SessionReset_ResetsIsHotGating() + { + var vwapsd = new Vwapsd(1.0); + var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42); + var bars = gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // Process bars until IsHot is true (WarmupPeriod = 2) + vwapsd.Update(bars[0]); + Assert.False(vwapsd.IsHot); + vwapsd.Update(bars[1]); + Assert.True(vwapsd.IsHot); + + // Process more bars to ensure we're well past warmup + for (int i = 2; i < 10; i++) + { + vwapsd.Update(bars[i]); + } + Assert.True(vwapsd.IsHot); + + // Reset session - IsHot should become false + var resetBar1 = new TBar(DateTime.UtcNow, 200, 210, 190, 200, 1000); + vwapsd.Update(resetBar1, isNew: true, reset: true); + Assert.False(vwapsd.IsHot, "IsHot should be false after reset (1 bar accumulated)"); + + // Process second bar after reset - IsHot should become true + var resetBar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 205, 215, 195, 205, 1100); + vwapsd.Update(resetBar2, isNew: true); + Assert.True(vwapsd.IsHot, "IsHot should be true after 2 bars accumulated post-reset"); + } + [Fact] public void Vwapsd_VwapFormula_MatchesExpected() { diff --git a/lib/channels/vwapsd/Vwapsd.Validation.Tests.cs b/lib/channels/vwapsd/Vwapsd.Validation.Tests.cs index 1fd3ffd3..a70c335b 100644 --- a/lib/channels/vwapsd/Vwapsd.Validation.Tests.cs +++ b/lib/channels/vwapsd/Vwapsd.Validation.Tests.cs @@ -106,12 +106,12 @@ public sealed class VwapsdValidationTests : IDisposable streamingLower.Add(streamingVwapsd.Lower.Value); } - // Span mode - using HLC3 for price + // Span mode - using bar.HLC3 for price to match streaming mode double[] price = new double[bars.Count]; double[] volume = new double[bars.Count]; for (int i = 0; i < bars.Count; i++) { - price[i] = (bars[i].High + bars[i].Low + bars[i].Close) / 3.0; + price[i] = bars[i].HLC3; volume[i] = bars[i].Volume; } diff --git a/lib/channels/vwapsd/Vwapsd.cs b/lib/channels/vwapsd/Vwapsd.cs index b7dc97ef..6cde04c3 100644 --- a/lib/channels/vwapsd/Vwapsd.cs +++ b/lib/channels/vwapsd/Vwapsd.cs @@ -132,8 +132,7 @@ public sealed class Vwapsd : AbstractBase [MethodImpl(MethodImplOptions.AggressiveInlining)] public TValue Update(TBar bar, bool isNew = true, bool reset = false) { - double hlc3 = (bar.High + bar.Low + bar.Close) / 3.0; - return Update(new TValue(bar.Time, hlc3), bar.Volume, isNew, reset); + return Update(new TValue(bar.Time, bar.HLC3), bar.Volume, isNew, reset); } /// @@ -168,6 +167,9 @@ public sealed class Vwapsd : AbstractBase // Handle reset if (reset || !_state.IsInitialized) { + // Reset warmup tracking for proper IsHot gating after session reset + _index = 1; + if (vol > 0) { _state = _state with diff --git a/lib/core/collections/MonotonicDeque.cs b/lib/core/collections/MonotonicDeque.cs new file mode 100644 index 00000000..58dece0c --- /dev/null +++ b/lib/core/collections/MonotonicDeque.cs @@ -0,0 +1,189 @@ +using System.Runtime.CompilerServices; + +namespace QuanTAlib; + +/// +/// A monotonic deque for O(1) amortized sliding window min/max queries. +/// Maintains elements in strictly monotonic order (non-increasing for max, non-decreasing for min). +/// Used by channel indicators (Donchian, MinMax) for efficient rolling extrema. +/// +/// +/// Algorithm: For each new element, expire indices outside the window, then pop elements +/// from the back that would violate monotonicity, then push the new index. +/// Time complexity: O(1) amortized per operation (each element pushed/popped at most once). +/// Space complexity: O(period) for the deque array. +/// +[SkipLocalsInit] +public sealed class MonotonicDeque +{ + private readonly int[] _deque; + private readonly int _period; + private int _head; + private int _count; + + /// + /// Gets the current front index (the index of the current extremum). + /// + public int FrontIndex => _count > 0 ? _deque[_head] : -1; + + /// + /// Gets the current element count in the deque. + /// + public int Count => _count; + + /// + /// Creates a new monotonic deque with the specified period. + /// + /// The sliding window size. + public MonotonicDeque(int period) + { + if (period <= 0) + throw new ArgumentOutOfRangeException(nameof(period), "Period must be greater than 0"); + + _period = period; + _deque = new int[period]; + _head = 0; + _count = 0; + } + + /// + /// Pushes a value for maximum tracking (maintains non-increasing order). + /// Smaller or equal values are removed from the back before pushing. + /// + /// The logical index of the value (used for expiration). + /// The value to push. + /// The circular buffer containing values (indexed by logicalIndex % period). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void PushMax(long logicalIndex, double value, double[] buffer) + { + // Expire old indices outside the window + long expire = logicalIndex - _period; + while (_count > 0 && _deque[_head] <= expire) + { + _head = (_head + 1) % _period; + _count--; + } + + // Pop elements from back that are <= value (maintain non-increasing order) + while (_count > 0) + { + int backIdx = (_head + _count - 1) % _period; + int bufIdx = _deque[backIdx] % _period; + if (buffer[bufIdx] <= value) + { + _count--; + } + else + { + break; + } + } + + // Push new index + int tail = (_head + _count) % _period; + _deque[tail] = (int)logicalIndex; + _count++; + } + + /// + /// Pushes a value for minimum tracking (maintains non-decreasing order). + /// Larger or equal values are removed from the back before pushing. + /// + /// The logical index of the value (used for expiration). + /// The value to push. + /// The circular buffer containing values (indexed by logicalIndex % period). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void PushMin(long logicalIndex, double value, double[] buffer) + { + // Expire old indices outside the window + long expire = logicalIndex - _period; + while (_count > 0 && _deque[_head] <= expire) + { + _head = (_head + 1) % _period; + _count--; + } + + // Pop elements from back that are >= value (maintain non-decreasing order) + while (_count > 0) + { + int backIdx = (_head + _count - 1) % _period; + int bufIdx = _deque[backIdx] % _period; + if (buffer[bufIdx] >= value) + { + _count--; + } + else + { + break; + } + } + + // Push new index + int tail = (_head + _count) % _period; + _deque[tail] = (int)logicalIndex; + _count++; + } + + /// + /// Gets the current extremum value from the buffer. + /// + /// The circular buffer containing values. + /// The value at the front of the deque, or NaN if empty. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public double GetExtremum(double[] buffer) + { + return _count > 0 ? buffer[_deque[_head] % _period] : double.NaN; + } + + /// + /// Resets the deque to empty state. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Reset() + { + _head = 0; + _count = 0; + } + + /// + /// Rebuilds the max deque from scratch using the buffer contents. + /// Used after bar corrections (isNew=false) to maintain consistency. + /// + /// The circular buffer containing values. + /// The current logical index. + /// The number of valid elements in the buffer. + public void RebuildMax(double[] buffer, long currentIndex, int count) + { + Reset(); + if (count == 0) return; + + long startLogical = currentIndex - count + 1; + for (int i = 0; i < count; i++) + { + long logicalIndex = startLogical + i; + int bufIdx = (int)(logicalIndex % _period); + PushMax(logicalIndex, buffer[bufIdx], buffer); + } + } + + /// + /// Rebuilds the min deque from scratch using the buffer contents. + /// Used after bar corrections (isNew=false) to maintain consistency. + /// + /// The circular buffer containing values. + /// The current logical index. + /// The number of valid elements in the buffer. + public void RebuildMin(double[] buffer, long currentIndex, int count) + { + Reset(); + if (count == 0) return; + + long startLogical = currentIndex - count + 1; + for (int i = 0; i < count; i++) + { + long logicalIndex = startLogical + i; + int bufIdx = (int)(logicalIndex % _period); + PushMin(logicalIndex, buffer[bufIdx], buffer); + } + } +} \ No newline at end of file diff --git a/lib/core/ringbuffer/RingBuffer.cs b/lib/core/ringbuffer/RingBuffer.cs index 5089f2a8..7f70e7b2 100644 --- a/lib/core/ringbuffer/RingBuffer.cs +++ b/lib/core/ringbuffer/RingBuffer.cs @@ -31,6 +31,13 @@ public sealed class RingBuffer : IEnumerable private double _savedSum; private double _savedValue; + /// + /// Immutable snapshot token for multi-buffer scenarios. + /// Allows capturing and restoring buffer state without using the built-in single snapshot. + /// + [StructLayout(LayoutKind.Auto)] + public readonly record struct SnapshotToken(int Head, int Count, double Sum, double Value); + /// /// Creates a new RingBuffer with the specified capacity. /// Uses pinned memory for SIMD compatibility. @@ -241,9 +248,7 @@ public sealed class RingBuffer : IEnumerable private double GetAt(Index index) { int actualIndex = index.IsFromEnd ? _count - index.Value : index.Value; -#pragma warning disable S3236 // Caller information arguments should not be provided explicitly - intentionally using cleaner parameter name ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual((uint)actualIndex, (uint)_count, nameof(index)); -#pragma warning restore S3236 int start = _count == Capacity ? _head : 0; int bufferIdx = (start + actualIndex) % Capacity; @@ -254,9 +259,7 @@ public sealed class RingBuffer : IEnumerable private void SetAt(Index index, double value) { int actualIndex = index.IsFromEnd ? _count - index.Value : index.Value; -#pragma warning disable S3236 // Caller information arguments should not be provided explicitly - intentionally using cleaner parameter name ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual((uint)actualIndex, (uint)_count, nameof(index)); -#pragma warning restore S3236 int start = _count == Capacity ? _head : 0; int bufferIdx = (start + actualIndex) % Capacity; @@ -272,7 +275,7 @@ public sealed class RingBuffer : IEnumerable /// If wrapped, returns span over a copy. /// /// - ///   Allocation Warning: When the buffer wraps around (i.e., when data spans + ///   Allocation Warning: When the buffer wraps around (i.e., when data spans /// from the end of the internal array back to the beginning), this method allocates a new /// array via to return contiguous data. For allocation-free iteration /// over wrapped buffers, use instead. @@ -569,6 +572,32 @@ public sealed class RingBuffer : IEnumerable _buffer[_head] = _savedValue; } + /// + /// Creates a snapshot token that captures the current buffer state. + /// Use this for multi-buffer scenarios where you need to snapshot multiple buffers atomically. + /// Must be called BEFORE adding a new value if you intend to restore later. + /// + /// An immutable token containing the buffer state. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public SnapshotToken GetSnapshot() + { + return new SnapshotToken(_head, _count, _sum, _buffer[_head]); + } + + /// + /// Restores the buffer to the state captured in the provided snapshot token. + /// Use this for multi-buffer scenarios where you need to restore multiple buffers atomically. + /// + /// The snapshot token to restore from. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void RestoreSnapshot(SnapshotToken token) + { + _head = token.Head; + _count = token.Count; + _sum = token.Sum; + _buffer[_head] = token.Value; + } + /// /// Returns an enumerator that iterates through the buffer in chronological order. /// diff --git a/lib/core/simd/ErrorHelpers.cs b/lib/core/simd/ErrorHelpers.cs index d4a75eca..e506b888 100644 --- a/lib/core/simd/ErrorHelpers.cs +++ b/lib/core/simd/ErrorHelpers.cs @@ -14,6 +14,18 @@ namespace QuanTAlib; /// public static class ErrorHelpers { + /// + /// Default stack allocation threshold for temporary buffers. + /// 256 doubles = 2KB, safe margin for nested calls on 1MB thread stack. + /// Beyond this threshold, ArrayPool is used instead of stackalloc. + /// + public const int StackAllocThreshold = 256; + + /// + /// Default resync interval for running sums to correct floating-point drift. + /// + public const int DefaultResyncInterval = 1000; + private const string SpanLengthMismatchMessage = "All spans must have the same length"; /// @@ -434,7 +446,6 @@ public static class ErrorHelpers if (len == 0) return; - const int StackAllocThreshold = 256; double[]? rented = null; #pragma warning disable S1121 // Assignments should not be made from within sub-expressions @@ -508,7 +519,6 @@ public static class ErrorHelpers if (len == 0) return; - const int StackAllocThreshold = 256; double[]? rented = null; #pragma warning disable S1121 // Assignments should not be made from within sub-expressions @@ -584,7 +594,6 @@ public static class ErrorHelpers if (len == 0) return; - const int StackAllocThreshold = 256; double[]? rentedErrors = null; double[]? rentedWeights = null; @@ -661,10 +670,49 @@ public static class ErrorHelpers } } - #region Private Helpers - + /// + /// Sanitizes input spans by replacing NaN/Infinity values with the last valid value. + /// Writes sanitized values to output spans for use in batch calculations. + /// + /// Input actual values + /// Input predicted values + /// Output sanitized actual values + /// Output sanitized predicted values [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static double FindFirstValidValue(ReadOnlySpan span) + public static void SanitizeInputs( + ReadOnlySpan actual, + ReadOnlySpan predicted, + Span actualOut, + Span predictedOut) + { + if (actual.Length != predicted.Length || actual.Length != actualOut.Length || actual.Length != predictedOut.Length) + throw new ArgumentException(SpanLengthMismatchMessage, nameof(predictedOut)); + + int len = actual.Length; + if (len == 0) + return; + + double lastValidActual = FindFirstValidValue(actual); + double lastValidPredicted = FindFirstValidValue(predicted); + + for (int i = 0; i < len; i++) + { + double act = actual[i]; + double pred = predicted[i]; + + if (double.IsFinite(act)) lastValidActual = act; else act = lastValidActual; + if (double.IsFinite(pred)) lastValidPredicted = pred; else pred = lastValidPredicted; + + actualOut[i] = act; + predictedOut[i] = pred; + } + } + + /// + /// Finds the first finite value in a span, or returns 0.0 if none found. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static double FindFirstValidValue(ReadOnlySpan span) { for (int i = 0; i < span.Length; i++) { @@ -674,6 +722,8 @@ public static class ErrorHelpers return 0.0; } + #region Private Helpers + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool IsDataClean(ReadOnlySpan actual, ReadOnlySpan predicted) { diff --git a/lib/cycles/stc/Stc.cs b/lib/cycles/stc/Stc.cs index 8f76832f..974e2949 100644 --- a/lib/cycles/stc/Stc.cs +++ b/lib/cycles/stc/Stc.cs @@ -23,8 +23,7 @@ public sealed class Stc : AbstractBase private bool _isNew; [StructLayout(LayoutKind.Sequential)] - #pragma warning disable CA1066 // Implement IEquatable because it overrides Equals - private struct State + private record struct State { public double FastEma; public double SlowEma; @@ -39,7 +38,6 @@ public sealed class Stc : AbstractBase public double Stoch1Min; public double Stoch1Max; } - #pragma warning restore CA1066 private State _s, _ps; private int _samples; @@ -119,84 +117,111 @@ public sealed class Stc : AbstractBase return Math.Clamp(x, 0, 100); } + /// + /// Applies final smoothing to stoch2Raw based on smoothing mode. + /// Shared between Update() and Calculate() to eliminate duplication. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static double ApplySmoothing(double stoch2Raw, StcSmoothing smoothing, double dAlpha, ref double stoch2Ema, ref double prevStc) + { + double stc; + switch (smoothing) + { + case StcSmoothing.Ema: + stoch2Ema = double.IsNaN(stoch2Ema) + ? stoch2Raw + : Math.FusedMultiplyAdd(dAlpha, stoch2Raw - stoch2Ema, stoch2Ema); + stc = Clamp100(stoch2Ema); + break; + + case StcSmoothing.Sigmoid: + stc = 100.0 / (1.0 + Math.Exp(-0.1 * (stoch2Raw - 50.0))); + break; + + case StcSmoothing.Digital: + if (stoch2Raw > 75) stc = 100; + else if (stoch2Raw < 25) stc = 0; + else stc = double.IsNaN(prevStc) ? stoch2Raw : prevStc; + break; + + default: // Includes StcSmoothing.None + stc = stoch2Raw; + break; + } + prevStc = stc; + return stc; + } + + /// + /// Updates min/max tracking for a sliding window. + /// Returns true if a full rescan is needed (removed value was at boundary). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool UpdateMinMaxCore(double added, double removed, bool hasRemoved, ref double min, ref double max) + { + if (double.IsNaN(added)) return false; + + bool expandMin = added < min; + bool expandMax = added > max; + + if (!hasRemoved) + { + if (expandMin) min = added; + if (expandMax) max = added; + return false; + } + + // Use relative tolerance for floating-point comparison + double tolerance = Math.Max(Math.Abs(min), Math.Abs(max)) * 1e-12; + if (tolerance < 1e-15) tolerance = 1e-15; // minimum absolute tolerance + bool removedMin = Math.Abs(removed - min) <= tolerance; + bool removedMax = Math.Abs(removed - max) <= tolerance; + + if (expandMin) min = added; + if (expandMax) max = added; + + return (removedMin && !expandMin) || (removedMax && !expandMax); + } + + /// + /// Rescans a span to find new min/max values. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void RescanMinMax(ReadOnlySpan span, ref double min, ref double max) + { + min = double.PositiveInfinity; + max = double.NegativeInfinity; + foreach (double v in span) + { + if (double.IsNaN(v)) continue; + if (v < min) min = v; + if (v > max) max = v; + } + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void UpdateMinMax(double added, double removed, bool hasRemoved, RingBuffer buf, ref double min, ref double max) { - if (double.IsNaN(added)) return; - - bool expandMin = added < min; - bool expandMax = added > max; - - if (!hasRemoved) - { - if (expandMin) min = added; - if (expandMax) max = added; - return; - } - - // Use relative tolerance for floating-point comparison - double tolerance = Math.Max(Math.Abs(min), Math.Abs(max)) * 1e-12; - if (tolerance < 1e-15) tolerance = 1e-15; // minimum absolute tolerance - bool removedMin = Math.Abs(removed - min) <= tolerance; - bool removedMax = Math.Abs(removed - max) <= tolerance; - - if (expandMin) min = added; - if (expandMax) max = added; - - if ((removedMin && !expandMin) || (removedMax && !expandMax)) + if (UpdateMinMaxCore(added, removed, hasRemoved, ref min, ref max)) { var span = buf.IsFull ? buf.InternalBuffer : buf.GetSpan(); - min = double.PositiveInfinity; - max = double.NegativeInfinity; - foreach (double v in span) - { - if (double.IsNaN(v)) continue; - if (v < min) min = v; - if (v > max) max = v; - } + RescanMinMax(span, ref min, ref max); } } - // Overload for Span based buffers (Calculate) [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void UpdateMinMax(double added, double removed, bool hasRemoved, ReadOnlySpan buf, ref double min, ref double max) { - if (double.IsNaN(added)) return; - - bool expandMin = added < min; - bool expandMax = added > max; - - if (!hasRemoved) + if (UpdateMinMaxCore(added, removed, hasRemoved, ref min, ref max)) { - if (expandMin) min = added; - if (expandMax) max = added; - return; - } - - // Use relative tolerance for floating-point comparison - double tolerance = Math.Max(Math.Abs(min), Math.Abs(max)) * 1e-12; - if (tolerance < 1e-15) tolerance = 1e-15; // minimum absolute tolerance - bool removedMin = Math.Abs(removed - min) <= tolerance; - bool removedMax = Math.Abs(removed - max) <= tolerance; - - if (expandMin) min = added; - if (expandMax) max = added; - - if ((removedMin && !expandMin) || (removedMax && !expandMax)) - { - min = double.PositiveInfinity; - max = double.NegativeInfinity; - foreach (double v in buf) - { - if (double.IsNaN(v)) continue; - if (v < min) min = v; - if (v > max) max = v; - } + RescanMinMax(buf, ref min, ref max); } } + // skipcq: CS-R1140 - Cyclomatic complexity justified: STC algorithm requires + // sequential MACD→Stoch1→Stoch2→Smoothing pipeline with min/max tracking per stage. + // Splitting would fragment the tightly-coupled state machine and harm readability. [MethodImpl(MethodImplOptions.AggressiveInlining)] - // skipcq: CS-R1140 public override TValue Update(TValue input, bool isNew = true) { _isNew = isNew; @@ -312,34 +337,7 @@ public sealed class Stc : AbstractBase double stc = double.NaN; if (!double.IsNaN(stoch2Raw)) { - switch (_smoothing) - { - case StcSmoothing.Ema: - s.Stoch2Ema = double.IsNaN(s.Stoch2Ema) - ? stoch2Raw - : Math.FusedMultiplyAdd(_dAlpha, stoch2Raw - s.Stoch2Ema, s.Stoch2Ema); - stc = Clamp100(s.Stoch2Ema); - break; - - case StcSmoothing.Sigmoid: - stc = 100.0 / (1.0 + Math.Exp(-0.1 * (stoch2Raw - 50.0))); - break; - - case StcSmoothing.Digital: - if (stoch2Raw > 75) stc = 100; - else if (stoch2Raw < 25) stc = 0; - else stc = double.IsNaN(s.PrevStc) ? stoch2Raw : s.PrevStc; - break; - - case StcSmoothing.None: - stc = stoch2Raw; - break; - - default: - stc = stoch2Raw; - break; - } - s.PrevStc = stc; + stc = ApplySmoothing(stoch2Raw, _smoothing, _dAlpha, ref s.Stoch2Ema, ref s.PrevStc); } if (isNew) _samples++; @@ -374,7 +372,19 @@ public sealed class Stc : AbstractBase base.Dispose(disposing); } - // skipcq: CS-R1140 + /// + /// Static convenience method that creates a new Stc instance and processes the entire series. + /// + public static TSeries Calculate(TSeries source, int kPeriod = 10, int dPeriod = 3, int fastLength = 23, int slowLength = 50, StcSmoothing smoothing = StcSmoothing.Ema) + { + var indicator = new Stc(kPeriod, dPeriod, fastLength, slowLength, smoothing); + return indicator.Update(source); + } + + // skipcq: CS-R1140 - Cyclomatic complexity justified: span-based Calculate must + // replicate the full STC state machine inline for zero-allocation performance. + // The sequential MACD→Stoch1→Stoch2→Smoothing pipeline cannot be decomposed + // without introducing heap allocations or sacrificing inlining opportunities. public static void Calculate(ReadOnlySpan source, Span output, int kPeriod = 10, int dPeriod = 3, int fastLength = 23, int slowLength = 50, StcSmoothing smoothing = StcSmoothing.Ema) { @@ -522,28 +532,7 @@ public sealed class Stc : AbstractBase double stc = double.NaN; if (!double.IsNaN(stoch2Raw)) { - if (smoothing == StcSmoothing.Ema) - { - stoch2Ema = double.IsNaN(stoch2Ema) - ? stoch2Raw - : Math.FusedMultiplyAdd(dAlpha, stoch2Raw - stoch2Ema, stoch2Ema); - stc = Clamp100(stoch2Ema); - } - else if (smoothing == StcSmoothing.Sigmoid) - { - stc = 100.0 / (1.0 + Math.Exp(-0.1 * (stoch2Raw - 50.0))); - } - else if (smoothing == StcSmoothing.Digital) - { - if (stoch2Raw > 75) stc = 100; - else if (stoch2Raw < 25) stc = 0; - else stc = double.IsNaN(prevStc) ? stoch2Raw : prevStc; - } - else - { - stc = stoch2Raw; - } - prevStc = stc; + stc = ApplySmoothing(stoch2Raw, smoothing, dAlpha, ref stoch2Ema, ref prevStc); } output[i] = stc; diff --git a/lib/errors/mase/Mase.cs b/lib/errors/mase/Mase.cs index 6345753a..b6dd1fc6 100644 --- a/lib/errors/mase/Mase.cs +++ b/lib/errors/mase/Mase.cs @@ -104,18 +104,15 @@ public sealed class Mase : AbstractBase { _state = _p_state; - // Incremental update for error buffer: get current newest, compute delta - double currentNewestError = _errorBuffer.Count > 0 ? _errorBuffer.Newest : 0.0; - double deltaError = absError - currentNewestError; - _state.ErrorSum += deltaError; + // Bar correction: update buffer and recalculate sums + // Note: _p_state was saved BEFORE the Add, but buffer still has the added value + // So we update newest and recalculate to ensure consistency _errorBuffer.UpdateNewest(absError); - - // Incremental update for scale buffer: get current newest, compute delta - double currentNewestScale = _scaleBuffer.Count > 0 ? _scaleBuffer.Newest : 0.0; - double deltaScale = naiveDiff - currentNewestScale; - _state.ScaleSum += deltaScale; _scaleBuffer.UpdateNewest(naiveDiff); + _state.ErrorSum = _errorBuffer.RecalculateSum(); + _state.ScaleSum = _scaleBuffer.RecalculateSum(); + _state.PrevActual = actualVal; } diff --git a/lib/errors/rae/Rae.cs b/lib/errors/rae/Rae.cs index 20de1e83..413efc6f 100644 --- a/lib/errors/rae/Rae.cs +++ b/lib/errors/rae/Rae.cs @@ -107,9 +107,7 @@ public sealed class Rae : AbstractBase { _state = _p_state; - // Update actual buffer - double removedActual = _actualBuffer.Count == _actualBuffer.Capacity ? _actualBuffer.Oldest : 0.0; - _state.ActualSum = _state.ActualSum - removedActual + actualVal; + // Update buffers and recalculate sums (buffer state is inconsistent with _p_state) _actualBuffer.UpdateNewest(actualVal); _state.ActualSum = _actualBuffer.RecalculateSum(); @@ -118,12 +116,9 @@ public sealed class Rae : AbstractBase double absError = Math.Abs(actualVal - predictedVal); double absBaseline = Math.Abs(actualVal - mean); - // Update error buffer _absErrorBuffer.UpdateNewest(absError); - _state.AbsErrorSum = _absErrorBuffer.RecalculateSum(); - - // Update baseline buffer _absBaselineBuffer.UpdateNewest(absBaseline); + _state.AbsErrorSum = _absErrorBuffer.RecalculateSum(); _state.AbsBaselineSum = _absBaselineBuffer.RecalculateSum(); } @@ -292,4 +287,4 @@ public sealed class Rae : AbstractBase } } } -} \ No newline at end of file +} diff --git a/lib/errors/rsquared/Rsquared.cs b/lib/errors/rsquared/Rsquared.cs index e6048871..af9ee56b 100644 --- a/lib/errors/rsquared/Rsquared.cs +++ b/lib/errors/rsquared/Rsquared.cs @@ -110,25 +110,22 @@ public sealed class Rsquared : AbstractBase { _state = _p_state; - // Update actual buffer - double removedActual = _actualBuffer.Count == _actualBuffer.Capacity ? _actualBuffer.Oldest : 0.0; - _state.ActualSum = _state.ActualSum - removedActual + actualVal; + // Bar correction: update buffers and recalculate sums _actualBuffer.UpdateNewest(actualVal); - _state.ActualSum = _actualBuffer.RecalculateSum(); // Calculate mean and errors - double mean = _state.ActualSum / _actualBuffer.Count; + double mean = _actualBuffer.RecalculateSum() / _actualBuffer.Count; + _state.ActualSum = _actualBuffer.RecalculateSum(); + double residual = actualVal - predictedVal; double totalDev = actualVal - mean; double sqResidual = residual * residual; double sqTotal = totalDev * totalDev; - // Update squared residual buffer _sqResidualBuffer.UpdateNewest(sqResidual); - _state.SqResidualSum = _sqResidualBuffer.RecalculateSum(); - - // Update squared total buffer _sqTotalBuffer.UpdateNewest(sqTotal); + + _state.SqResidualSum = _sqResidualBuffer.RecalculateSum(); _state.SqTotalSum = _sqTotalBuffer.RecalculateSum(); } diff --git a/lib/errors/theilu/TheilU.cs b/lib/errors/theilu/TheilU.cs index 325e37fc..02255867 100644 --- a/lib/errors/theilu/TheilU.cs +++ b/lib/errors/theilu/TheilU.cs @@ -124,14 +124,15 @@ public sealed class TheilU : AbstractBase { _state = _p_state; - // Simplified: just update buffers and recalculate sums (no redundant arithmetic) + // Bar correction: update buffer and recalculate sums + // Note: _p_state was saved BEFORE the Add, but buffer still has the added value + // So we update newest and recalculate to ensure consistency _sqErrorBuffer.UpdateNewest(sqError); - _state.SqErrorSum = _sqErrorBuffer.RecalculateSum(); - _sqActualBuffer.UpdateNewest(sqActual); - _state.SqActualSum = _sqActualBuffer.RecalculateSum(); - _sqPredBuffer.UpdateNewest(sqPred); + + _state.SqErrorSum = _sqErrorBuffer.RecalculateSum(); + _state.SqActualSum = _sqActualBuffer.RecalculateSum(); _state.SqPredSum = _sqPredBuffer.RecalculateSum(); } diff --git a/lib/errors/tukey/TukeyBiweight.cs b/lib/errors/tukey/TukeyBiweight.cs index e715b508..3b74581b 100644 --- a/lib/errors/tukey/TukeyBiweight.cs +++ b/lib/errors/tukey/TukeyBiweight.cs @@ -29,6 +29,7 @@ public sealed class TukeyBiweight : BiInputIndicatorBase { private readonly double _cSquaredOver6; private const double DefaultC = 4.685; // 95% efficiency for normal distribution + private const int BatchResyncInterval = 1000; // Local constant for static Batch method public TukeyBiweight(int period, double c = DefaultC) : base(period, $"TukeyBiweight({period},{c:F3})") @@ -104,7 +105,7 @@ public sealed class TukeyBiweight : BiInputIndicatorBase ErrorHelpers.ComputeTukeyBiweightErrors(actual, predicted, errors, c); // Step 2: Apply rolling mean - ErrorHelpers.ApplyRollingMean(errors, output, period, ResyncInterval); + ErrorHelpers.ApplyRollingMean(errors, output, period, BatchResyncInterval); } finally { diff --git a/lib/errors/wmape/Wmape.cs b/lib/errors/wmape/Wmape.cs index 684001f3..73529848 100644 --- a/lib/errors/wmape/Wmape.cs +++ b/lib/errors/wmape/Wmape.cs @@ -97,11 +97,13 @@ public sealed class Wmape : AbstractBase } else { - // Simplified: just update buffers and recalculate sums (no redundant arithmetic) + // Bar correction: update buffer and recalculate sums + // Note: _p_state was saved BEFORE the Add, but buffer still has the added value + // So we update newest and recalculate to ensure consistency _absErrorBuffer.UpdateNewest(absError); - _state.AbsErrorSum = _absErrorBuffer.RecalculateSum(); - _absActualBuffer.UpdateNewest(absActual); + + _state.AbsErrorSum = _absErrorBuffer.RecalculateSum(); _state.AbsActualSum = _absActualBuffer.RecalculateSum(); } diff --git a/lib/filters/gauss/Gauss.cs b/lib/filters/gauss/Gauss.cs index 0838d6bf..965d49e8 100644 --- a/lib/filters/gauss/Gauss.cs +++ b/lib/filters/gauss/Gauss.cs @@ -25,13 +25,11 @@ public sealed class Gauss : AbstractBase private State _p_state; [StructLayout(LayoutKind.Auto)] - #pragma warning disable CA1066 // Implement IEquatable because it overrides Equals - private struct State + private record struct State { public double LastValue; public bool IsHot; } - #pragma warning restore CA1066 /// /// Gets the kernel size used for the filter. diff --git a/lib/filters/hann/Hann.cs b/lib/filters/hann/Hann.cs index 3cb10d30..f38a72ee 100644 --- a/lib/filters/hann/Hann.cs +++ b/lib/filters/hann/Hann.cs @@ -20,13 +20,11 @@ public sealed class Hann : AbstractBase private State _p_state; [StructLayout(LayoutKind.Auto)] - #pragma warning disable CA1066 // Implement IEquatable because it overrides Equals - private struct State + private record struct State { public double LastValue; public bool IsHot; } - #pragma warning restore CA1066 /// /// Gets the length of the window (period). diff --git a/lib/filters/hp/Hp.cs b/lib/filters/hp/Hp.cs index fbb469e4..e81e0d12 100644 --- a/lib/filters/hp/Hp.cs +++ b/lib/filters/hp/Hp.cs @@ -25,15 +25,13 @@ public sealed class Hp : AbstractBase private State _p_state; [StructLayout(LayoutKind.Auto)] - #pragma warning disable CA1066 // Implement IEquatable because it overrides Equals - private struct State + private record struct State { public double Trend; public double PrevTrend; public double PrevPrice; public bool IsInitialized; } - #pragma warning restore CA1066 /// /// Smoothing parameter (lambda). Common values: 1600 (Quarterly), 14400 (Monthly), 6.25 (Annual). diff --git a/lib/filters/hpf/Hpf.cs b/lib/filters/hpf/Hpf.cs index 29d3628f..744cb446 100644 --- a/lib/filters/hpf/Hpf.cs +++ b/lib/filters/hpf/Hpf.cs @@ -22,8 +22,7 @@ public sealed class Hpf : AbstractBase private State _pState; [StructLayout(LayoutKind.Sequential)] - #pragma warning disable CA1066 // Implement IEquatable because it overrides Equals - private struct State + private record struct State { public double Hp1; public double Hp2; @@ -32,7 +31,6 @@ public sealed class Hpf : AbstractBase public int Samples; public bool HasSrc; } - #pragma warning restore CA1066 public int Length { get; } diff --git a/lib/filters/kalman/Kalman.cs b/lib/filters/kalman/Kalman.cs index 1d555b56..6bf017a2 100644 --- a/lib/filters/kalman/Kalman.cs +++ b/lib/filters/kalman/Kalman.cs @@ -42,14 +42,12 @@ public sealed class Kalman : AbstractBase private State _pState; [StructLayout(LayoutKind.Sequential)] - #pragma warning disable CA1066 // Implement IEquatable because it overrides Equals - private struct State + private record struct State { public double X; public double P; public int Samples; } - #pragma warning restore CA1066 /// /// Process noise covariance. Defaults to 0.01. diff --git a/lib/filters/loess/Loess.cs b/lib/filters/loess/Loess.cs index 35350446..97ee863b 100644 --- a/lib/filters/loess/Loess.cs +++ b/lib/filters/loess/Loess.cs @@ -28,14 +28,12 @@ public sealed class Loess : AbstractBase private Snapshot _pSnap; [StructLayout(LayoutKind.Sequential)] - #pragma warning disable CA1066 // Implement IEquatable because it overrides Equals - private struct Snapshot + private record struct Snapshot { public double LastOutput; public double LastFiniteInput; public bool HasFiniteInput; } - #pragma warning restore CA1066 /// /// Gets the period of the filter. diff --git a/lib/filters/notch/Notch.cs b/lib/filters/notch/Notch.cs index d3e5e9ae..e24c0865 100644 --- a/lib/filters/notch/Notch.cs +++ b/lib/filters/notch/Notch.cs @@ -15,13 +15,11 @@ public sealed class Notch : AbstractBase private State _p_state; [StructLayout(LayoutKind.Auto)] - #pragma warning disable CA1066 // Implement IEquatable because it overrides Equals - private struct State + private record struct State { public double X1, X2, Y1, Y2; public double LastValue; } - #pragma warning restore CA1066 public int NotchFreq { get; } public double Bandwidth { get; } diff --git a/lib/filters/usf/Usf.cs b/lib/filters/usf/Usf.cs index 9ecf845e..1424c0e5 100644 --- a/lib/filters/usf/Usf.cs +++ b/lib/filters/usf/Usf.cs @@ -123,7 +123,10 @@ public sealed class Usf : AbstractBase double usf = (_state.Count < 4) ? val - : (1.0 - _c1) * val + (2.0 * _c1 - _c2) * _state.PrevInput1 - (_c1 + _c3) * _state.PrevInput2 + _c2 * _state.Usf1 + _c3 * _state.Usf2; + : Math.FusedMultiplyAdd(_c3, _state.Usf2, + Math.FusedMultiplyAdd(_c2, _state.Usf1, + Math.FusedMultiplyAdd(_k2, _state.PrevInput2, + Math.FusedMultiplyAdd(_k1, _state.PrevInput1, _k0 * val)))); _state.Usf2 = _state.Usf1; _state.Usf1 = usf; diff --git a/lib/numerics/Atan2Validation.Tests.cs b/lib/numerics/Atan2Validation.Tests.cs new file mode 100644 index 00000000..dab902ca --- /dev/null +++ b/lib/numerics/Atan2Validation.Tests.cs @@ -0,0 +1,351 @@ +using System; +using Xunit; +using Xunit.Abstractions; + +namespace QuanTAlib.Tests; + +/// +/// Validates the behavioral differences between .NET's Math.Atan2 and PineScript's custom atan2 implementation. +/// +/// PineScript's atan2 uses a numerically stable algorithm: +/// 1. If |x| > |y|: angle = atan(|y|/|x|) +/// 2. If |y| >= |x|: angle = Ï€/2 - atan(|x|/|y|) +/// 3. Then applies quadrant correction based on sign of x and y +/// +/// Math.Atan2 follows the standard IEEE convention: atan2(y, x) returns the angle +/// in radians between the positive x-axis and the point (x, y). +/// +/// Both return values in the range [-Ï€, Ï€], but they can differ in edge cases +/// and have different numerical stability characteristics. +/// +public class Atan2ValidationTests +{ + private readonly ITestOutputHelper _output; + + public Atan2ValidationTests(ITestOutputHelper output) + { + _output = output; + } + + /// + /// PineScript-style atan2 implementation for comparison. + /// This is a direct port of the algorithm from ht_phasor.pine. + /// + private static double PineScriptAtan2(double y, double x) + { + if (y == 0.0 && x == 0.0) + throw new ArgumentException("atan2: Both y and x cannot be zero", nameof(y)); + + double ay = Math.Abs(y); + double ax = Math.Abs(x); + double angle; + + if (ax > ay) + { + angle = Math.Atan(ay / ax); + } + else + { + angle = (Math.PI / 2.0) - Math.Atan(ax / ay); + } + + if (x < 0.0) + angle = Math.PI - angle; + if (y < 0.0) + angle = -angle; + + return angle; + } + + [Fact] + public void Atan2_StandardQuadrant1_BothMatch() + { + // First quadrant: x > 0, y > 0 + double y = 1.0, x = 1.0; + double dotNet = Math.Atan2(y, x); + double pine = PineScriptAtan2(y, x); + + _output.WriteLine($"Q1: Math.Atan2({y}, {x}) = {dotNet:F15}"); + _output.WriteLine($"Q1: PineAtan2({y}, {x}) = {pine:F15}"); + _output.WriteLine($"Q1: Difference = {Math.Abs(dotNet - pine):E}"); + + Assert.Equal(dotNet, pine, precision: 14); + } + + [Fact] + public void Atan2_StandardQuadrant2_BothMatch() + { + // Second quadrant: x < 0, y > 0 + double y = 1.0, x = -1.0; + double dotNet = Math.Atan2(y, x); + double pine = PineScriptAtan2(y, x); + + _output.WriteLine($"Q2: Math.Atan2({y}, {x}) = {dotNet:F15}"); + _output.WriteLine($"Q2: PineAtan2({y}, {x}) = {pine:F15}"); + _output.WriteLine($"Q2: Difference = {Math.Abs(dotNet - pine):E}"); + + Assert.Equal(dotNet, pine, precision: 14); + } + + [Fact] + public void Atan2_StandardQuadrant3_BothMatch() + { + // Third quadrant: x < 0, y < 0 + double y = -1.0, x = -1.0; + double dotNet = Math.Atan2(y, x); + double pine = PineScriptAtan2(y, x); + + _output.WriteLine($"Q3: Math.Atan2({y}, {x}) = {dotNet:F15}"); + _output.WriteLine($"Q3: PineAtan2({y}, {x}) = {pine:F15}"); + _output.WriteLine($"Q3: Difference = {Math.Abs(dotNet - pine):E}"); + + Assert.Equal(dotNet, pine, precision: 14); + } + + [Fact] + public void Atan2_StandardQuadrant4_BothMatch() + { + // Fourth quadrant: x > 0, y < 0 + double y = -1.0, x = 1.0; + double dotNet = Math.Atan2(y, x); + double pine = PineScriptAtan2(y, x); + + _output.WriteLine($"Q4: Math.Atan2({y}, {x}) = {dotNet:F15}"); + _output.WriteLine($"Q4: PineAtan2({y}, {x}) = {pine:F15}"); + _output.WriteLine($"Q4: Difference = {Math.Abs(dotNet - pine):E}"); + + Assert.Equal(dotNet, pine, precision: 14); + } + + [Fact] + public void Atan2_AxisAligned_PositiveY() + { + // On positive Y-axis: x = 0, y > 0 → Ï€/2 + double y = 1.0, x = 0.0; + double dotNet = Math.Atan2(y, x); + double pine = PineScriptAtan2(y, x); + double expected = Math.PI / 2.0; + + _output.WriteLine($"Positive Y-axis: Math.Atan2({y}, {x}) = {dotNet:F15}"); + _output.WriteLine($"Positive Y-axis: PineAtan2({y}, {x}) = {pine:F15}"); + _output.WriteLine($"Expected: Ï€/2 = {expected:F15}"); + + Assert.Equal(expected, dotNet, precision: 14); + Assert.Equal(expected, pine, precision: 14); + } + + [Fact] + public void Atan2_AxisAligned_NegativeY() + { + // On negative Y-axis: x = 0, y < 0 → -Ï€/2 + double y = -1.0, x = 0.0; + double dotNet = Math.Atan2(y, x); + double pine = PineScriptAtan2(y, x); + double expected = -Math.PI / 2.0; + + _output.WriteLine($"Negative Y-axis: Math.Atan2({y}, {x}) = {dotNet:F15}"); + _output.WriteLine($"Negative Y-axis: PineAtan2({y}, {x}) = {pine:F15}"); + _output.WriteLine($"Expected: -Ï€/2 = {expected:F15}"); + + Assert.Equal(expected, dotNet, precision: 14); + Assert.Equal(expected, pine, precision: 14); + } + + [Fact] + public void Atan2_AxisAligned_PositiveX() + { + // On positive X-axis: x > 0, y = 0 → 0 + double y = 0.0, x = 1.0; + double dotNet = Math.Atan2(y, x); + double pine = PineScriptAtan2(y, x); + double expected = 0.0; + + _output.WriteLine($"Positive X-axis: Math.Atan2({y}, {x}) = {dotNet:F15}"); + _output.WriteLine($"Positive X-axis: PineAtan2({y}, {x}) = {pine:F15}"); + _output.WriteLine($"Expected: 0 = {expected:F15}"); + + Assert.Equal(expected, dotNet, precision: 14); + Assert.Equal(expected, pine, precision: 14); + } + + [Fact] + public void Atan2_AxisAligned_NegativeX() + { + // On negative X-axis: x < 0, y = 0 → Ï€ + double y = 0.0, x = -1.0; + double dotNet = Math.Atan2(y, x); + double pine = PineScriptAtan2(y, x); + double expected = Math.PI; + + _output.WriteLine($"Negative X-axis: Math.Atan2({y}, {x}) = {dotNet:F15}"); + _output.WriteLine($"Negative X-axis: PineAtan2({y}, {x}) = {pine:F15}"); + _output.WriteLine($"Expected: Ï€ = {expected:F15}"); + + Assert.Equal(expected, dotNet, precision: 14); + Assert.Equal(expected, pine, precision: 14); + } + + [Fact] + public void Atan2_Origin_DotNetReturnsZero_PineThrows() + { + // Origin: x = 0, y = 0 + // Math.Atan2 returns 0 (by convention) + // PineScript throws an error + double y = 0.0, x = 0.0; + double dotNet = Math.Atan2(y, x); + + _output.WriteLine($"Origin: Math.Atan2({y}, {x}) = {dotNet:F15}"); + _output.WriteLine("Origin: PineScript throws ArgumentException"); + + Assert.Equal(0.0, dotNet); + Assert.Throws(() => PineScriptAtan2(y, x)); + } + + [Fact] + public void Atan2_VerySmallValues_NumericalStability() + { + // Test numerical stability with very small values + double y = 1e-300, x = 1e-300; + double dotNet = Math.Atan2(y, x); + double pine = PineScriptAtan2(y, x); + double expected = Math.PI / 4.0; // 45 degrees + + _output.WriteLine($"Small values: Math.Atan2({y:E}, {x:E}) = {dotNet:F15}"); + _output.WriteLine($"Small values: PineAtan2({y:E}, {x:E}) = {pine:F15}"); + _output.WriteLine($"Expected: Ï€/4 = {expected:F15}"); + _output.WriteLine($"Difference = {Math.Abs(dotNet - pine):E}"); + + // Both should handle small values well + Assert.Equal(expected, dotNet, precision: 10); + Assert.Equal(expected, pine, precision: 10); + } + + [Fact] + public void Atan2_VeryLargeValues_NumericalStability() + { + // Test numerical stability with very large values + double y = 1e300, x = 1e300; + double dotNet = Math.Atan2(y, x); + double pine = PineScriptAtan2(y, x); + double expected = Math.PI / 4.0; // 45 degrees + + _output.WriteLine($"Large values: Math.Atan2({y:E}, {x:E}) = {dotNet:F15}"); + _output.WriteLine($"Large values: PineAtan2({y:E}, {x:E}) = {pine:F15}"); + _output.WriteLine($"Expected: Ï€/4 = {expected:F15}"); + _output.WriteLine($"Difference = {Math.Abs(dotNet - pine):E}"); + + Assert.Equal(expected, dotNet, precision: 10); + Assert.Equal(expected, pine, precision: 10); + } + + [Fact] + public void Atan2_AspectRatioExtreme_TallVector() + { + // PineScript algorithm switches behavior when |y| > |x| + // Test with y >> x + double y = 1000.0, x = 1.0; + double dotNet = Math.Atan2(y, x); + double pine = PineScriptAtan2(y, x); + + _output.WriteLine($"Tall vector: Math.Atan2({y}, {x}) = {dotNet:F15}"); + _output.WriteLine($"Tall vector: PineAtan2({y}, {x}) = {pine:F15}"); + _output.WriteLine($"Difference = {Math.Abs(dotNet - pine):E}"); + + // Should be very close to Ï€/2 + Assert.True(Math.Abs(dotNet - pine) < 1e-12, $"Difference {Math.Abs(dotNet - pine):E} exceeds tolerance"); + } + + [Fact] + public void Atan2_AspectRatioExtreme_WideVector() + { + // Test with x >> y + double y = 1.0, x = 1000.0; + double dotNet = Math.Atan2(y, x); + double pine = PineScriptAtan2(y, x); + + _output.WriteLine($"Wide vector: Math.Atan2({y}, {x}) = {dotNet:F15}"); + _output.WriteLine($"Wide vector: PineAtan2({y}, {x}) = {pine:F15}"); + _output.WriteLine($"Difference = {Math.Abs(dotNet - pine):E}"); + + // Should be very close to 0 + Assert.True(Math.Abs(dotNet - pine) < 1e-12, $"Difference {Math.Abs(dotNet - pine):E} exceeds tolerance"); + } + + [Theory] + [InlineData(0.5, 0.866025403784439)] // 30 degrees + [InlineData(0.707106781186548, 0.707106781186548)] // 45 degrees + [InlineData(0.866025403784439, 0.5)] // 60 degrees + public void Atan2_CommonAngles_Match(double y, double x) + { + double dotNet = Math.Atan2(y, x); + double pine = PineScriptAtan2(y, x); + + _output.WriteLine($"Math.Atan2({y}, {x}) = {dotNet:F15} rad = {dotNet * 180 / Math.PI:F10}°"); + _output.WriteLine($"PineAtan2({y}, {x}) = {pine:F15} rad = {pine * 180 / Math.PI:F10}°"); + _output.WriteLine($"Difference = {Math.Abs(dotNet - pine):E}"); + + Assert.Equal(dotNet, pine, precision: 13); + } + + [Fact] + public void Atan2_FullCircle_360DegreesSweep() + { + // Sweep through 360 degrees and verify both implementations match + const int steps = 360; + double maxDiff = 0; + int maxDiffStep = 0; + + for (int i = 0; i < steps; i++) + { + double angle = i * 2.0 * Math.PI / steps; + double y = Math.Sin(angle); + double x = Math.Cos(angle); + + // Skip origin (angle = 0 with x=1, y=0 is fine, but need to handle numerical zeros) + if (Math.Abs(x) < 1e-15 && Math.Abs(y) < 1e-15) + continue; + + double dotNet = Math.Atan2(y, x); + double pine = PineScriptAtan2(y, x); + double diff = Math.Abs(dotNet - pine); + + if (diff > maxDiff) + { + maxDiff = diff; + maxDiffStep = i; + } + } + + _output.WriteLine($"Full circle sweep: {steps} steps"); + _output.WriteLine($"Maximum difference: {maxDiff:E} at step {maxDiffStep} ({maxDiffStep}°)"); + + // Expect very small differences + Assert.True(maxDiff < 1e-14, $"Maximum difference {maxDiff:E} exceeds tolerance"); + } + + [Fact] + public void Summary_ImplementationDifferences() + { + _output.WriteLine("=== Math.Atan2 vs PineScript atan2 Summary ==="); + _output.WriteLine(""); + _output.WriteLine("1. Origin handling:"); + _output.WriteLine(" - Math.Atan2(0, 0) returns 0"); + _output.WriteLine(" - PineScript throws an error"); + _output.WriteLine(""); + _output.WriteLine("2. Algorithm:"); + _output.WriteLine(" - Math.Atan2: IEEE standard implementation"); + _output.WriteLine(" - PineScript: Uses |y|/|x| or |x|/|y| based on which is larger"); + _output.WriteLine(" This avoids division by very small numbers"); + _output.WriteLine(""); + _output.WriteLine("3. Numerical stability:"); + _output.WriteLine(" - Both handle extreme values well"); + _output.WriteLine(" - PineScript's approach may be slightly more stable for extreme aspect ratios"); + _output.WriteLine(""); + _output.WriteLine("4. Recommendation:"); + _output.WriteLine(" - Use Math.Atan2 for general purposes (standard, well-tested)"); + _output.WriteLine(" - PineScript algorithm adds ~zero benefit in .NET"); + _output.WriteLine(" - Only difference is origin handling (error vs 0)"); + + Assert.True(true); // This is a documentation test + } +} \ No newline at end of file diff --git a/lib/statistics/sum/Sum.cs b/lib/statistics/sum/Sum.cs index 3f2c33b6..cae44c41 100644 --- a/lib/statistics/sum/Sum.cs +++ b/lib/statistics/sum/Sum.cs @@ -45,7 +45,7 @@ public sealed class Sum : AbstractBase private readonly TValuePublishedHandler _handler; [StructLayout(LayoutKind.Auto)] - private struct State + private record struct State { public double Sum; // Accumulated sum public double C; // First-order compensation diff --git a/lib/trends_FIR/hamma/Hamma.cs b/lib/trends_FIR/hamma/Hamma.cs index d627c045..8ef2a0de 100644 --- a/lib/trends_FIR/hamma/Hamma.cs +++ b/lib/trends_FIR/hamma/Hamma.cs @@ -43,7 +43,7 @@ public sealed class Hamma : AbstractBase private bool _isNew = true; [StructLayout(LayoutKind.Auto)] - private struct State + private record struct State { public double LastValidValue; public bool IsInitialized; diff --git a/lib/trends_IIR/frama/Frama.cs b/lib/trends_IIR/frama/Frama.cs index 873a1971..fa3425b7 100644 --- a/lib/trends_IIR/frama/Frama.cs +++ b/lib/trends_IIR/frama/Frama.cs @@ -29,7 +29,7 @@ public sealed class Frama : ITValuePublisher private readonly TValuePublishedHandler _handler; [StructLayout(LayoutKind.Sequential)] - private struct State + private record struct State { public double Frama; public double LastHigh; diff --git a/lib/trends_IIR/hema/Hema.cs b/lib/trends_IIR/hema/Hema.cs index 324da6d4..364e93a9 100644 --- a/lib/trends_IIR/hema/Hema.cs +++ b/lib/trends_IIR/hema/Hema.cs @@ -30,7 +30,7 @@ public sealed class Hema : AbstractBase private const double Ln2 = 0.693147180559945309417232121458176568; [StructLayout(LayoutKind.Sequential)] - private struct State + private record struct State { public double EmaSlowRaw; public double EmaFastRaw; diff --git a/lib/trends_IIR/mama/Mama.cs b/lib/trends_IIR/mama/Mama.cs index b2a7cab6..e6c0081e 100644 --- a/lib/trends_IIR/mama/Mama.cs +++ b/lib/trends_IIR/mama/Mama.cs @@ -210,9 +210,12 @@ public sealed class Mama : AbstractBase double alpha = _scaledFastLimit / delta; alpha = Math.Clamp(alpha, _slowLimit, _fastLimit); - // Final indicators - _state.Mama = alpha * _priceBuffer[^1] + (1.0 - alpha) * _p_state.Mama; - _state.Fama = FamaAlphaFactor * alpha * _state.Mama + (1.0 - FamaAlphaFactor * alpha) * _p_state.Fama; + // Final indicators (using FMA for precision) + double decay = 1.0 - alpha; + _state.Mama = Math.FusedMultiplyAdd(_p_state.Mama, decay, alpha * _priceBuffer[^1]); + double famaAlpha = FamaAlphaFactor * alpha; + double famaDecay = 1.0 - famaAlpha; + _state.Fama = Math.FusedMultiplyAdd(_p_state.Fama, famaDecay, famaAlpha * _state.Mama); } else { @@ -442,9 +445,12 @@ public sealed class Mama : AbstractBase double alpha = scaledFastLimit / delta; alpha = Math.Clamp(alpha, slowLimit, fastLimit); - // Final indicators - mama = alpha * priceBuffer[bufferIdx] + (1.0 - alpha) * p_mama; - fama = FamaAlphaFactor * alpha * mama + (1.0 - FamaAlphaFactor * alpha) * p_fama; + // Final indicators (using FMA for precision) + double decay = 1.0 - alpha; + mama = Math.FusedMultiplyAdd(p_mama, decay, alpha * priceBuffer[bufferIdx]); + double famaAlpha = FamaAlphaFactor * alpha; + double famaDecay = 1.0 - famaAlpha; + fama = Math.FusedMultiplyAdd(p_fama, famaDecay, famaAlpha * mama); // Update previous state p_i2 = i2; diff --git a/ndepend/badges/classes.svg b/ndepend/badges/classes.svg index 500370f6..3bf8c5f4 100644 --- a/ndepend/badges/classes.svg +++ b/ndepend/badges/classes.svg @@ -1,6 +1,6 @@ - # Classes: 661 + # Classes: 714 @@ -16,7 +16,7 @@ # Classes - - 661 + + 714 \ No newline at end of file diff --git a/ndepend/badges/comments.svg b/ndepend/badges/comments.svg index 647b0533..9e798000 100644 --- a/ndepend/badges/comments.svg +++ b/ndepend/badges/comments.svg @@ -1,6 +1,6 @@ - Percentage of Comments: 30.23 + Percentage of Comments: 30.85 @@ -16,7 +16,7 @@ Percentage of Comments - - 30.23 + + 30.85 \ No newline at end of file diff --git a/ndepend/badges/complexity.svg b/ndepend/badges/complexity.svg index 77259ab7..06a3fcf4 100644 --- a/ndepend/badges/complexity.svg +++ b/ndepend/badges/complexity.svg @@ -1,6 +1,6 @@ - Average Cyclomatic Complexity for Methods: 2.24 + Average Cyclomatic Complexity for Methods: 2.21 @@ -16,7 +16,7 @@ Average Cyclomatic Complexity for Methods - - 2.24 + + 2.21 \ No newline at end of file diff --git a/ndepend/badges/files.svg b/ndepend/badges/files.svg index db421f08..d34c16a9 100644 --- a/ndepend/badges/files.svg +++ b/ndepend/badges/files.svg @@ -1,6 +1,6 @@ - # Source Files: 686 + # Source Files: 723 @@ -16,7 +16,7 @@ # Source Files - - 686 + + 723 \ No newline at end of file diff --git a/ndepend/badges/loc.svg b/ndepend/badges/loc.svg index 66205c7b..8e87a1e7 100644 --- a/ndepend/badges/loc.svg +++ b/ndepend/badges/loc.svg @@ -1,6 +1,6 @@ - # Lines of Code: 82613 + # Lines of Code: 88083 @@ -16,7 +16,7 @@ # Lines of Code - - 82613 + + 88083 \ No newline at end of file diff --git a/ndepend/badges/methods.svg b/ndepend/badges/methods.svg index 0d91b011..727b88b1 100644 --- a/ndepend/badges/methods.svg +++ b/ndepend/badges/methods.svg @@ -1,6 +1,6 @@ - # Methods: 8813 + # Methods: 9375 @@ -16,7 +16,7 @@ # Methods - - 8813 + + 9375 \ No newline at end of file diff --git a/ndepend/badges/public-api.svg b/ndepend/badges/public-api.svg index 31f60455..496abf5c 100644 --- a/ndepend/badges/public-api.svg +++ b/ndepend/badges/public-api.svg @@ -1,6 +1,6 @@ - # Public Types: 798 + # Public Types: 862 @@ -16,7 +16,7 @@ # Public Types - - 798 + + 862 \ No newline at end of file