diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 138acc89..55ac567f 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -3,38 +3,164 @@ # Documentation: https://docs.coderabbit.ai/reference/configuration language: en-US -tone_instructions: "Focus on performance, memory allocation, SIMD optimization, and numerical accuracy. Flag any heap allocations in hot paths." early_access: true enable_free_tier: true +tone_instructions: | + Voice: Direct, technically precise, skeptical-architect perspective. Persuade through evidence and measurable claims, not marketing speak. + + DO: + - Be ruthless with math/correctness, kind to humans + - Use precise numbers and quantified impacts (e.g., "3-4% divergence", "~15 cycles per op") + - Steel-man alternatives before rebutting with data + - Follow evidence chain: Why โ†’ How โ†’ Proof โ†’ So what + - Vary sentence length deliberately; use direct cadence + - Treat code as primary evidence + - Include concrete performance implications (cycles, allocations, memory footprint) + + DON'T: + - Use forbidden words: delve, leverage, pivotal, tapestry, landscape, furthermore, "is all about", "unlock the power", transformative, foster, seamless, ecosystem + - Use em-dashes or formulaic balanced pro/con structures + - Say "we" - be direct + - Make humor in performance, security, or math correctness claims + - Be vague - quantify everything possible + + PRIORITIES (this library): + - Zero heap allocations in hot paths (GC pressure is the enemy) + - SIMD/FMA optimization opportunities + - O(1) streaming updates + - NaN/Infinity robustness + - State rollback correctness (isNew=false) + - Numerical stability at edge cases + reviews: - profile: assertive # More thorough for a performance-critical library + profile: assertive request_changes_workflow: false high_level_summary: true high_level_summary_placeholder: "@coderabbitai summary" + high_level_summary_instructions: | + Generate a concise technical summary optimized for a high-performance C# trading library. + + FORMAT: + ## Summary + [1-2 sentence TL;DR: what changed and why it matters for performance/correctness] + + ## Changes by Category + + ### ๐Ÿš€ Performance + - [SIMD/FMA optimizations, allocation reductions, O(1) improvements] + + ### ๐Ÿ”ง Indicators + - [New/modified indicators with parameter signatures] + + ### ๐Ÿ›ก๏ธ Robustness + - [NaN/Infinity handling, state rollback fixes, edge cases] + + ### ๐Ÿงช Testing + - [Validation coverage, new test categories] + + ### ๐Ÿ“š Documentation + - [Doc updates, validation matrix changes] + + ### ๐Ÿ”Œ Quantower + - [Adapter additions/changes] + + (Omit empty categories) + + ## Impact Assessment + | Metric | Before | After | Delta | + |--------|--------|-------|-------| + | Allocations in hot path | ? | ? | ? | + | SIMD coverage | ? | ? | ? | + | Test coverage | ? | ? | ? | + + (Include table only when quantifiable changes exist) + + ## Breaking Changes + - [List any API breaks with migration path, or "None"] + + auto_title_instructions: | + Generate PR title following conventional commit format: : [scope] + + TYPES (required prefix): + - feat: New indicator, API addition, capability + - perf: SIMD optimization, allocation reduction, O(1) improvement + - fix: Bug fix, numerical stability, edge case handling + - refactor: Code restructure without behavior change + - test: Test additions, validation coverage + - docs: Documentation, markdown updates + + RULES: + - Subject line โ‰ค50 characters + - Use imperative mood ("add", "fix", "optimize" not "added", "fixes", "optimizes") + - Include [scope] when change is localized (e.g., [Ema], [RingBuffer], [Quantower]) + - For performance changes, append brief metric hint if space allows + + EXAMPLES: + - feat: add Jma indicator with adaptive smoothing [trends_IIR] + - perf: eliminate allocations in Sma.Update hot path + - fix: handle NaN in Rsi state rollback [isNew=false] + - refactor: extract SIMD helpers to core/simd + - test: add validation tests for Ema vs TA-Lib + - docs: update validation matrix for momentum indicators + + AVOID: + - Vague titles ("update code", "fix bug", "improvements") + - Past tense ("added", "fixed") + - Articles ("add the", "fix a") + - Exceeding 50 chars (truncate scope if needed) + review_status: true commit_status: true collapse_walkthrough: false changed_files_summary: true - sequence_diagrams: false # Not useful for indicator calculations + sequence_diagrams: false estimate_code_review_effort: true assess_linked_issues: true related_issues: true related_prs: true suggested_labels: true suggested_reviewers: true - poem: false # Keep it professional + poem: false path_filters: - # Include all source files - - "**/*.cs" - - "**/*.md" - - "**/*.yaml" - - "**/*.yml" - - "**/*.csproj" - - "**/*.props" + # --- Core Infrastructure --- + - "lib/core/**/*.cs" + - "lib/feeds/**/*.cs" + - "lib/numerics/**/*.cs" - # Exclude build/IDE artifacts + # --- Trend Indicators --- + - "lib/trends_FIR/**/*.cs" + - "lib/trends_IIR/**/*.cs" + + # --- Oscillators & Momentum --- + - "lib/oscillators/**/*.cs" + - "lib/momentum/**/*.cs" + + # --- Channels & Volatility --- + - "lib/channels/**/*.cs" + - "lib/volatility/**/*.cs" + + # --- Dynamics & Reversals --- + - "lib/dynamics/**/*.cs" + - "lib/reversals/**/*.cs" + + # --- Statistics & Errors --- + - "lib/statistics/**/*.cs" + - "lib/errors/**/*.cs" + + # --- Cycles, Filters, Forecasts --- + - "lib/cycles/**/*.cs" + - "lib/filters/**/*.cs" + - "lib/forecasts/**/*.cs" + + # --- Volume --- + - "lib/volume/**/*.cs" + + # --- Quantower Adapters --- + - "quantower/**/*.cs" + + # --- Always Excluded --- - "!**/bin/**" - "!**/obj/**" - "!**/.vs/**" @@ -45,19 +171,38 @@ reviews: - "!**/_site/**" - "!**/ndepend/NDependOut/**" - "!**/perf/publish/**" + - "!**/temp/**" + - "!**/*.sarif" + - "!**/*.snupkg" + - "!**/*.nupkg" path_instructions: - path: "**/**" instructions: | - - ensure thread safety, zero allocations, and proper Span/Memory usage." - - verify numerical stability, check for SIMD and FMA opportunities - - verify RingBuffer usage, ensure O(1) updates validate state rollback for isNew=false. - - check for numerical stability at edge cases, verify NaN/Infinity handling + QuanTAlib code review checklist: + + MEMORY & PERFORMANCE: - No heap allocations in Update() methods - Use Math.FusedMultiplyAdd for a*b+c patterns - - Support isNew=false for bar correction (state rollback) + - [MethodImpl(AggressiveInlining)] for hot paths + - SIMD opportunities in Calculate(Span) methods + - Verify O(1) streaming updates where math allows + + STATE & CORRECTNESS: + - Support isNew=false for bar correction (state rollback via _p_state) - Handle NaN/Infinity with last-valid-value substitution - - Use [MethodImpl(AggressiveInlining)] for hot paths + - Verify RingBuffer usage for sliding windows + - Check numerical stability at edge cases (overflow, underflow, div/0) + + API CONSISTENCY: + - Dual API: stateful Update + stateless static Calculate + - ArgumentException with nameof(param) for validation + - ITValuePublisher implementation for reactive chaining + + PATTERNS: + - State as private record struct + - List for SoA storage (suppress MA0016 locally) + - DateTime.UtcNow (never DateTime.Now) auto_review: enabled: true @@ -72,12 +217,19 @@ reviews: finishing_touches: docstrings: - enabled: false # XML docs handled separately + enabled: false unit_tests: - enabled: false # Tests excluded from review + enabled: false tools: - # Disable tools not relevant to C# + ast-grep: + rule_dirs: + - ".coderabbit/ast-grep-rules" + util_dirs: [] + essential_rules: true + packages: + - "ast-grep-essentials" + shellcheck: enabled: false ruff: @@ -99,13 +251,12 @@ reviews: detekt: enabled: false - # Keep enabled for config files yamllint: enabled: true markdownlint: enabled: true gitleaks: - enabled: true # Security - detect secrets + enabled: true github-checks: enabled: true timeout_ms: 90000 @@ -126,4 +277,4 @@ knowledge_base: issues: scope: auto pull_requests: - scope: auto \ No newline at end of file + scope: auto diff --git a/.coderabbit/ast-grep-rules/no-datetime-now.yaml b/.coderabbit/ast-grep-rules/no-datetime-now.yaml new file mode 100644 index 00000000..0ee6ea41 --- /dev/null +++ b/.coderabbit/ast-grep-rules/no-datetime-now.yaml @@ -0,0 +1,11 @@ +# QuanTAlib: Enforce DateTime.UtcNow over DateTime.Now +# DCT rule: always use DateTime.UtcNow (never DateTime.Now) + +id: no-datetime-now +language: csharp +severity: error +message: "Use DateTime.UtcNow instead of DateTime.Now - QuanTAlib requires UTC timestamps for consistency" +note: "DateTime.Now includes local timezone which causes issues in distributed trading systems" +rule: + pattern: DateTime.Now +fix: DateTime.UtcNow diff --git a/.coderabbit/ast-grep-rules/no-random-in-tests.yaml b/.coderabbit/ast-grep-rules/no-random-in-tests.yaml new file mode 100644 index 00000000..c85c1c8a --- /dev/null +++ b/.coderabbit/ast-grep-rules/no-random-in-tests.yaml @@ -0,0 +1,10 @@ +# QuanTAlib: Forbid System.Random in tests - use GBM helper instead +# DCT rule: Tests MUST use GBM helper, never System.Random + +id: no-random-in-tests +language: csharp +severity: error +message: "Use GBM helper instead of System.Random in tests - ensures reproducible test data" +note: "GBM (Geometric Brownian Motion) provides consistent, realistic price data for indicator testing" +rule: + pattern: new Random($$$) diff --git a/.coderabbit/ast-grep-rules/require-nameof-in-exceptions.yaml b/.coderabbit/ast-grep-rules/require-nameof-in-exceptions.yaml new file mode 100644 index 00000000..67491bb1 --- /dev/null +++ b/.coderabbit/ast-grep-rules/require-nameof-in-exceptions.yaml @@ -0,0 +1,13 @@ +# QuanTAlib: Require nameof() in ArgumentException +# DCT rule: ArgumentException + nameof(param) for proper analyzer support + +id: require-nameof-in-exceptions +language: csharp +severity: warning +message: "Use nameof(parameter) instead of string literal in ArgumentException for refactoring safety" +note: "MA0015-friendly: nameof() ensures parameter name stays in sync during refactoring" +rule: + any: + - pattern: throw new ArgumentException($MSG, "$PARAM") + - pattern: throw new ArgumentNullException("$PARAM") + - pattern: throw new ArgumentOutOfRangeException("$PARAM") diff --git a/.coderabbit/ast-grep-rules/suggest-fma.yaml b/.coderabbit/ast-grep-rules/suggest-fma.yaml new file mode 100644 index 00000000..88fa0a85 --- /dev/null +++ b/.coderabbit/ast-grep-rules/suggest-fma.yaml @@ -0,0 +1,12 @@ +# QuanTAlib: Suggest FMA for multiply-add patterns +# DCT rule: Math.FusedMultiplyAdd(a, b, c) for a*b+c patterns in hot paths + +id: suggest-fma-multiply-add +language: csharp +severity: hint +message: "Consider Math.FusedMultiplyAdd(a, b, c) for better precision and performance in hot paths" +note: "FMA provides single-rounding semantics and can be faster on modern CPUs. Use for EMA smoothing, IIR filters, weighted sums." +rule: + any: + - pattern: $A * $B + $C + - pattern: $A + $B * $C diff --git a/.coderabbit/ast-grep-rules/warn-linq-methods.yaml b/.coderabbit/ast-grep-rules/warn-linq-methods.yaml new file mode 100644 index 00000000..57ed79f7 --- /dev/null +++ b/.coderabbit/ast-grep-rules/warn-linq-methods.yaml @@ -0,0 +1,37 @@ +# QuanTAlib: Warn about LINQ in potential hot paths +# DCT rule 1: Hot paths allocation-free (no heap alloc); GC pressure enemy + +id: warn-linq-methods +language: csharp +severity: warning +message: "LINQ method detected - verify this is not in a hot path (Update/Calculate). LINQ allocates and causes GC pressure." +note: "DCT rule 1: Hot paths must be allocation-free. Replace LINQ with for loops or Span operations in performance-critical code." +rule: + any: + - pattern: $EXPR.Where($$$) + - pattern: $EXPR.Select($$$) + - pattern: $EXPR.OrderBy($$$) + - pattern: $EXPR.OrderByDescending($$$) + - pattern: $EXPR.GroupBy($$$) + - pattern: $EXPR.ToList() + - pattern: $EXPR.ToArray() + - pattern: $EXPR.ToDictionary($$$) + - pattern: $EXPR.First($$$) + - pattern: $EXPR.FirstOrDefault($$$) + - pattern: $EXPR.Last($$$) + - pattern: $EXPR.LastOrDefault($$$) + - pattern: $EXPR.Single($$$) + - pattern: $EXPR.SingleOrDefault($$$) + - pattern: $EXPR.Any($$$) + - pattern: $EXPR.All($$$) + - pattern: $EXPR.Count($$$) + - pattern: $EXPR.Sum($$$) + - pattern: $EXPR.Average($$$) + - pattern: $EXPR.Min($$$) + - pattern: $EXPR.Max($$$) + - pattern: $EXPR.Aggregate($$$) + - pattern: $EXPR.Distinct($$$) + - pattern: $EXPR.Skip($$$) + - pattern: $EXPR.Take($$$) + - pattern: $EXPR.Zip($$$) + - pattern: $EXPR.Concat($$$) diff --git a/lib/core/simd/ErrorHelpers.cs b/lib/core/simd/ErrorHelpers.cs index b3786d8b..d4a75eca 100644 --- a/lib/core/simd/ErrorHelpers.cs +++ b/lib/core/simd/ErrorHelpers.cs @@ -39,7 +39,7 @@ public static class ErrorHelpers // Try SIMD path - NaN detection is integrated into the SIMD loop if (Avx2.IsSupported && len >= Vector256.Count) { - int processedCount = ComputeSignedErrorsSimdWithNaNDetection(actual, predicted, output, lastValidActual, lastValidPredicted); + int processedCount = ComputeSignedErrorsSimdWithNaNDetection(actual, predicted, output, ref lastValidActual, ref lastValidPredicted); if (processedCount == len) return; // All processed via SIMD // Continue with scalar for remaining elements (NaN was detected) @@ -74,7 +74,7 @@ public static class ErrorHelpers // Try SIMD path - NaN detection is integrated into the SIMD loop (avoids double-pass) if (Avx2.IsSupported && len >= Vector256.Count) { - int processedCount = ComputeAbsoluteErrorsSimdWithNaNDetection(actual, predicted, output, lastValidActual, lastValidPredicted); + int processedCount = ComputeAbsoluteErrorsSimdWithNaNDetection(actual, predicted, output, ref lastValidActual, ref lastValidPredicted); if (processedCount == len) return; // All processed via SIMD // Continue with scalar for remaining elements (NaN was detected) @@ -88,7 +88,7 @@ public static class ErrorHelpers /// /// Computes squared errors: (actual - predicted)ยฒ - /// Uses AVX2 SIMD when available for clean data, with scalar fallback for NaN handling. + /// Uses AVX2 SIMD when available with integrated NaN detection, with scalar fallback for NaN handling. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void ComputeSquaredErrors( @@ -106,10 +106,14 @@ public static class ErrorHelpers double lastValidActual = FindFirstValidValue(actual); double lastValidPredicted = FindFirstValidValue(predicted); - // Try SIMD path for clean data (no NaN/Inf) - if (Avx2.IsSupported && len >= Vector256.Count && IsDataClean(actual, predicted)) + // Try SIMD path - NaN detection is integrated into the SIMD loop (avoids double-pass) + if (Avx2.IsSupported && len >= Vector256.Count) { - ComputeSquaredErrorsSimd(actual, predicted, output); + int processedCount = ComputeSquaredErrorsSimdWithNaNDetection(actual, predicted, output, ref lastValidActual, ref lastValidPredicted); + if (processedCount == len) + return; // All processed via SIMD + // Continue with scalar for remaining elements (NaN was detected) + ComputeSquaredErrorsScalar(actual.Slice(processedCount), predicted.Slice(processedCount), output.Slice(processedCount), lastValidActual, lastValidPredicted); return; } @@ -187,19 +191,15 @@ public static class ErrorHelpers double act = actual[i]; double pred = predicted[i]; - if (double.IsFinite(act)) currentValidActual = act; else act = currentValidActual; - if (double.IsFinite(pred)) currentValidPredicted = pred; else pred = currentValidPredicted; +#pragma warning disable S1121 // Assignments should not be made from within sub-expressions + act = double.IsFinite(act) ? (currentValidActual = act) : currentValidActual; + pred = double.IsFinite(pred) ? (currentValidPredicted = pred) : currentValidPredicted; +#pragma warning restore S1121 double absActual = Math.Abs(act); - if (absActual < epsilon) - { - // Avoid division by zero - use absolute error as fallback - output[i] = Math.Abs(act - pred); - } - else - { - output[i] = Math.Abs(act - pred) / absActual * 100.0; - } + output[i] = absActual < epsilon + ? Math.Abs(act - pred) + : Math.Abs(act - pred) / absActual * 100.0; } } @@ -236,14 +236,9 @@ public static class ErrorHelpers if (double.IsFinite(pred)) currentValidPredicted = pred; else pred = currentValidPredicted; double denominator = (Math.Abs(act) + Math.Abs(pred)) / 2.0; - if (denominator < epsilon) - { - output[i] = 0.0; // Both values near zero - } - else - { - output[i] = Math.Abs(act - pred) / denominator * 100.0; - } + output[i] = denominator < epsilon + ? 0.0 // Both values near zero + : Math.Abs(act - pred) / denominator * 100.0; } } @@ -413,14 +408,9 @@ public static class ErrorHelpers double diff = act - pred; double absDiff = Math.Abs(diff); - if (absDiff <= delta) - { - output[i] = 0.5 * diff * diff; - } - else - { - output[i] = delta * (absDiff - halfDelta); - } + output[i] = absDiff <= delta + ? 0.5 * diff * diff + : delta * (absDiff - halfDelta); } } @@ -735,14 +725,15 @@ public static class ErrorHelpers /// /// SIMD path with integrated NaN detection. Returns the number of elements processed. /// If NaN is detected, returns the index where NaN was found so caller can continue with scalar. + /// Updates lastValidActual/lastValidPredicted to track last seen finite values for scalar continuation. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int ComputeSignedErrorsSimdWithNaNDetection( ReadOnlySpan actual, ReadOnlySpan predicted, Span output, - double lastValidActual, - double lastValidPredicted) + ref double lastValidActual, + ref double lastValidPredicted) { int len = actual.Length; int vectorSize = Vector256.Count; @@ -762,7 +753,12 @@ public static class ErrorHelpers int mask = Avx.MoveMask(combined); if (mask != 0b1111) { - // NaN detected - return current position for scalar fallback + // NaN detected - update lastValid from previously processed elements before returning + if (i > 0) + { + lastValidActual = actual[i - 1]; + lastValidPredicted = predicted[i - 1]; + } return i; } @@ -771,6 +767,13 @@ public static class ErrorHelpers errorVec.StoreUnsafe(ref MemoryMarshal.GetReference(output.Slice(i))); } + // Update lastValid from end of SIMD-processed section + if (i > 0) + { + lastValidActual = actual[i - 1]; + lastValidPredicted = predicted[i - 1]; + } + // Handle scalar remainder for (; i < len; i++) { @@ -783,6 +786,8 @@ public static class ErrorHelpers return i; } + lastValidActual = act; + lastValidPredicted = pred; output[i] = act - pred; } @@ -845,14 +850,15 @@ public static class ErrorHelpers /// /// SIMD path with integrated NaN detection for absolute errors. Returns the number of elements processed. /// If NaN is detected, returns the index where NaN was found so caller can continue with scalar. + /// Updates lastValidActual/lastValidPredicted to track last seen finite values for scalar continuation. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int ComputeAbsoluteErrorsSimdWithNaNDetection( ReadOnlySpan actual, ReadOnlySpan predicted, Span output, - double lastValidActual, - double lastValidPredicted) + ref double lastValidActual, + ref double lastValidPredicted) { int len = actual.Length; int vectorSize = Vector256.Count; @@ -875,7 +881,12 @@ public static class ErrorHelpers int mask = Avx.MoveMask(combined); if (mask != 0b1111) { - // NaN detected - return current position for scalar fallback + // NaN detected - update lastValid from previously processed elements before returning + if (i > 0) + { + lastValidActual = actual[i - 1]; + lastValidPredicted = predicted[i - 1]; + } return i; } @@ -885,6 +896,13 @@ public static class ErrorHelpers absErrorVec.StoreUnsafe(ref MemoryMarshal.GetReference(output.Slice(i))); } + // Update lastValid from end of SIMD-processed section + if (i > 0) + { + lastValidActual = actual[i - 1]; + lastValidPredicted = predicted[i - 1]; + } + // Handle scalar remainder for (; i < len; i++) { @@ -897,12 +915,88 @@ public static class ErrorHelpers return i; } + lastValidActual = act; + lastValidPredicted = pred; output[i] = Math.Abs(act - pred); } return len; } + /// + /// SIMD path with integrated NaN detection for squared errors. Returns the number of elements processed. + /// If NaN is detected, returns the index where NaN was found so caller can continue with scalar. + /// Updates lastValidActual/lastValidPredicted to track last seen finite values for scalar continuation. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int ComputeSquaredErrorsSimdWithNaNDetection( + ReadOnlySpan actual, + ReadOnlySpan predicted, + Span output, + ref double lastValidActual, + ref double lastValidPredicted) + { + int len = actual.Length; + int vectorSize = Vector256.Count; + int vectorEnd = len - (len % vectorSize); + + int i = 0; + for (; i < vectorEnd; i += vectorSize) + { + Vector256 actVec = Vector256.LoadUnsafe(ref MemoryMarshal.GetReference(actual.Slice(i))); + Vector256 predVec = Vector256.LoadUnsafe(ref MemoryMarshal.GetReference(predicted.Slice(i))); + + // Check for NaN/Inf: x == x is false for NaN + Vector256 actCmp = Avx.Compare(actVec, actVec, FloatComparisonMode.OrderedNonSignaling); + Vector256 predCmp = Avx.Compare(predVec, predVec, FloatComparisonMode.OrderedNonSignaling); + Vector256 combined = Avx.And(actCmp, predCmp); + + int mask = Avx.MoveMask(combined); + if (mask != 0b1111) + { + // NaN detected - update lastValid from previously processed elements before returning + if (i > 0) + { + lastValidActual = actual[i - 1]; + lastValidPredicted = predicted[i - 1]; + } + return i; + } + + // No NaN - compute squared error: (actual - predicted)ยฒ + Vector256 errorVec = Avx.Subtract(actVec, predVec); + Vector256 sqErrorVec = Avx.Multiply(errorVec, errorVec); + sqErrorVec.StoreUnsafe(ref MemoryMarshal.GetReference(output.Slice(i))); + } + + // Update lastValid from end of SIMD-processed section + if (i > 0) + { + lastValidActual = actual[i - 1]; + lastValidPredicted = predicted[i - 1]; + } + + // Handle scalar remainder + for (; i < len; i++) + { + double act = actual[i]; + double pred = predicted[i]; + + if (!double.IsFinite(act) || !double.IsFinite(pred)) + { + // Return current position - caller will handle with scalar fallback + return i; + } + + lastValidActual = act; + lastValidPredicted = pred; + double diff = act - pred; + output[i] = diff * diff; + } + + return len; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void ComputeAbsoluteErrorsSimd( ReadOnlySpan actual, diff --git a/lib/core/simd/SimdExtensions.Tests.cs b/lib/core/simd/SimdExtensions.Tests.cs index 377bbff7..7e8583d9 100644 --- a/lib/core/simd/SimdExtensions.Tests.cs +++ b/lib/core/simd/SimdExtensions.Tests.cs @@ -289,18 +289,18 @@ public class SimdExtensionsTests // VarianceSIMD tests [Fact] - public void VarianceSIMD_LessThanTwoElements_ReturnsNaN() + public void VarianceSIMD_LessThanTwoElements_ReturnsZero() { double[] data = [42.5]; var span = new ReadOnlySpan(data); - Assert.True(double.IsNaN(span.VarianceSIMD())); + Assert.Equal(0.0, span.VarianceSIMD()); } [Fact] - public void VarianceSIMD_EmptySpan_ReturnsNaN() + public void VarianceSIMD_EmptySpan_ReturnsZero() { var span = ReadOnlySpan.Empty; - Assert.True(double.IsNaN(span.VarianceSIMD())); + Assert.Equal(0.0, span.VarianceSIMD()); } [Fact] @@ -631,8 +631,8 @@ public class SimdExtensionsTests Assert.True(variance > 0); Assert.True(stdDev > 0); - Assert.True(sw.ElapsedMilliseconds < 50, - $"SIMD operations took {sw.ElapsedMilliseconds}ms, expected < 50ms"); + Assert.True(sw.ElapsedMilliseconds < 100, + $"SIMD operations took {sw.ElapsedMilliseconds}ms, expected < 100ms"); } [Fact] @@ -870,26 +870,26 @@ public class SimdScalarFallbackTests } [Fact] - public void VarianceSIMD_SingleElement_ReturnsNaN() + public void VarianceSIMD_SingleElement_ReturnsZero() { double[] data = [42.5]; var span = new ReadOnlySpan(data); - Assert.True(double.IsNaN(span.VarianceSIMD())); + Assert.Equal(0.0, span.VarianceSIMD()); } [Fact] - public void StdDevSIMD_SingleElement_ReturnsNaN() + public void StdDevSIMD_SingleElement_ReturnsZero() { double[] data = [42.5]; var span = new ReadOnlySpan(data); - Assert.True(double.IsNaN(span.StdDevSIMD())); + Assert.Equal(0.0, span.StdDevSIMD()); } [Fact] - public void StdDevSIMD_EmptySpan_ReturnsNaN() + public void StdDevSIMD_EmptySpan_ReturnsZero() { var span = ReadOnlySpan.Empty; - Assert.True(double.IsNaN(span.StdDevSIMD())); + Assert.Equal(0.0, span.StdDevSIMD()); } [Fact] @@ -992,4 +992,4 @@ public class SimdScalarFallbackTests Assert.Throws(() => SimdExtensions.Subtract(left, right, result)); } -} +} \ No newline at end of file diff --git a/lib/core/simd/SimdExtensions.cs b/lib/core/simd/SimdExtensions.cs index 43428698..0a8c07c1 100644 --- a/lib/core/simd/SimdExtensions.cs +++ b/lib/core/simd/SimdExtensions.cs @@ -299,7 +299,8 @@ public static class SimdExtensions [MethodImpl(MethodImplOptions.AggressiveInlining)] public static double VarianceSIMD(this ReadOnlySpan span, double? mean = null) { - if (span.Length < 2) return double.NaN; + // Match VarianceScalar behavior: return 0.0 for length <= 1 to avoid inconsistency + if (span.Length <= 1) return 0.0; double m; if (mean.HasValue) @@ -627,32 +628,44 @@ public static class SimdExtensions ref double aRef = ref MemoryMarshal.GetReference(a); ref double bRef = ref MemoryMarshal.GetReference(b); + // Hoist FMA check outside loop for branch prediction optimization + bool useFma = Fma.IsSupported; + // Unroll loop: Process 16 doubles (4 vectors) at a time if (len >= 16) { - for (; i <= len - 16; i += 16) + if (useFma) { - var va1 = Vector256.LoadUnsafe(ref Unsafe.Add(ref aRef, i)); - var vb1 = Vector256.LoadUnsafe(ref Unsafe.Add(ref bRef, i)); - - var va2 = Vector256.LoadUnsafe(ref Unsafe.Add(ref aRef, i + 4)); - var vb2 = Vector256.LoadUnsafe(ref Unsafe.Add(ref bRef, i + 4)); - - var va3 = Vector256.LoadUnsafe(ref Unsafe.Add(ref aRef, i + 8)); - var vb3 = Vector256.LoadUnsafe(ref Unsafe.Add(ref bRef, i + 8)); - - var va4 = Vector256.LoadUnsafe(ref Unsafe.Add(ref aRef, i + 12)); - var vb4 = Vector256.LoadUnsafe(ref Unsafe.Add(ref bRef, i + 12)); - - if (Fma.IsSupported) + for (; i <= len - 16; i += 16) { + var va1 = Vector256.LoadUnsafe(ref Unsafe.Add(ref aRef, i)); + var vb1 = Vector256.LoadUnsafe(ref Unsafe.Add(ref bRef, i)); + var va2 = Vector256.LoadUnsafe(ref Unsafe.Add(ref aRef, i + 4)); + var vb2 = Vector256.LoadUnsafe(ref Unsafe.Add(ref bRef, i + 4)); + var va3 = Vector256.LoadUnsafe(ref Unsafe.Add(ref aRef, i + 8)); + var vb3 = Vector256.LoadUnsafe(ref Unsafe.Add(ref bRef, i + 8)); + var va4 = Vector256.LoadUnsafe(ref Unsafe.Add(ref aRef, i + 12)); + var vb4 = Vector256.LoadUnsafe(ref Unsafe.Add(ref bRef, i + 12)); + vSum = Fma.MultiplyAdd(va1, vb1, vSum); vSum2 = Fma.MultiplyAdd(va2, vb2, vSum2); vSum3 = Fma.MultiplyAdd(va3, vb3, vSum3); vSum4 = Fma.MultiplyAdd(va4, vb4, vSum4); } - else + } + else + { + for (; i <= len - 16; i += 16) { + var va1 = Vector256.LoadUnsafe(ref Unsafe.Add(ref aRef, i)); + var vb1 = Vector256.LoadUnsafe(ref Unsafe.Add(ref bRef, i)); + var va2 = Vector256.LoadUnsafe(ref Unsafe.Add(ref aRef, i + 4)); + var vb2 = Vector256.LoadUnsafe(ref Unsafe.Add(ref bRef, i + 4)); + var va3 = Vector256.LoadUnsafe(ref Unsafe.Add(ref aRef, i + 8)); + var vb3 = Vector256.LoadUnsafe(ref Unsafe.Add(ref bRef, i + 8)); + var va4 = Vector256.LoadUnsafe(ref Unsafe.Add(ref aRef, i + 12)); + var vb4 = Vector256.LoadUnsafe(ref Unsafe.Add(ref bRef, i + 12)); + vSum = Avx.Add(vSum, Avx.Multiply(va1, vb1)); vSum2 = Avx.Add(vSum2, Avx.Multiply(va2, vb2)); vSum3 = Avx.Add(vSum3, Avx.Multiply(va3, vb3)); @@ -661,15 +674,24 @@ public static class SimdExtensions } } - // Process remaining vectors (4 doubles at a time) - for (; i <= len - 4; i += 4) + // Process remaining vectors (4 doubles at a time) with hoisted branch + if (useFma) { - var va = Vector256.LoadUnsafe(ref Unsafe.Add(ref aRef, i)); - var vb = Vector256.LoadUnsafe(ref Unsafe.Add(ref bRef, i)); - - vSum = Fma.IsSupported - ? Fma.MultiplyAdd(va, vb, vSum) - : Avx.Add(vSum, Avx.Multiply(va, vb)); + for (; i <= len - 4; i += 4) + { + var va = Vector256.LoadUnsafe(ref Unsafe.Add(ref aRef, i)); + var vb = Vector256.LoadUnsafe(ref Unsafe.Add(ref bRef, i)); + vSum = Fma.MultiplyAdd(va, vb, vSum); + } + } + else + { + for (; i <= len - 4; i += 4) + { + var va = Vector256.LoadUnsafe(ref Unsafe.Add(ref aRef, i)); + var vb = Vector256.LoadUnsafe(ref Unsafe.Add(ref bRef, i)); + vSum = Avx.Add(vSum, Avx.Multiply(va, vb)); + } } // Combine accumulators diff --git a/lib/core/tbar/TBar.Tests.cs b/lib/core/tbar/TBar.Tests.cs index 2508a3f8..fd90474b 100644 --- a/lib/core/tbar/TBar.Tests.cs +++ b/lib/core/tbar/TBar.Tests.cs @@ -395,7 +395,7 @@ public class TBarTests } [Fact] - public void TBar_WithInfinity_HandlesGracefully() + public void TBar_WithPositiveInfinity_HandlesGracefully() { var bar = new TBar(12345, 100, double.PositiveInfinity, 90, 105, 1000); @@ -404,6 +404,17 @@ public class TBarTests Assert.True(double.IsPositiveInfinity(bar.HL2)); // Uses High } + [Fact] + public void TBar_WithNegativeInfinity_HandlesGracefully() + { + var bar = new TBar(12345, 100, 110, double.NegativeInfinity, 105, 1000); + + Assert.True(double.IsNegativeInfinity(bar.Low)); + Assert.True(double.IsNegativeInfinity(bar.L.Value)); + Assert.True(double.IsNegativeInfinity(bar.HL2)); // Uses Low + Assert.True(double.IsNegativeInfinity(bar.HLC3)); // Uses Low + } + [Fact] public void TBar_WithMaxValue_HandlesGracefully() { @@ -412,8 +423,8 @@ public class TBarTests 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)); + // HL2 = (MaxValue + MinValue) * 0.5 = 0 (symmetric around zero) + Assert.Equal(0.0, bar.HL2); } [Fact] @@ -497,4 +508,4 @@ public class TBarTests // (90 + 120 + 60) / 3 = 270 / 3 = 90 Assert.Equal(90.0, bar.OHL3); } -} +} \ No newline at end of file diff --git a/lib/core/tbar/tbar.cs b/lib/core/tbar/tbar.cs index 4a0f2776..72f419d8 100644 --- a/lib/core/tbar/tbar.cs +++ b/lib/core/tbar/tbar.cs @@ -23,11 +23,19 @@ public readonly record struct TBar(long Time, double Open, double High, double L // Computed properties (calculated on demand, no storage overhead) public double HL2 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => (High + Low) * 0.5; } public double OC2 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => (Open + Close) * 0.5; } - public double OHL3 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => (Open + High + Low) / 3.0; } - public double HLC3 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => (High + Low + Close) / 3.0; } + public double OHL3 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => (Open + High + Low) * (1.0 / 3.0); } + public double HLC3 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => (High + Low + Close) * (1.0 / 3.0); } public double OHLC4 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => (Open + High + Low + Close) * 0.25; } public double HLCC4 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => (High + Low + Close + Close) * 0.25; } + /// + /// Creates a TBar from DateTime and OHLCV values. + /// + /// + /// Performance warning: If .Kind is not , + /// is called, which allocates. For hot paths, prefer the + /// primary constructor with pre-computed UTC ticks. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public TBar(DateTime time, double open, double high, double low, double close, double volume) : this(time.Kind == DateTimeKind.Utc ? time.Ticks : time.ToUniversalTime().Ticks, open, high, low, close, volume) diff --git a/lib/core/tbarseries/tbarseries.cs b/lib/core/tbarseries/tbarseries.cs index dd22d898..fba07cf5 100644 --- a/lib/core/tbarseries/tbarseries.cs +++ b/lib/core/tbarseries/tbarseries.cs @@ -281,6 +281,11 @@ public class TBarSeries : IReadOnlyList public void Add(DateTime time, double open, double high, double low, double close, double volume, bool isNew = true) => Add(new TBar(time.Ticks, open, high, low, close, volume), isNew); + /// + /// Bulk add from IEnumerable sources. Fires Pub event for each bar. + /// WARNING: This method may allocate if inputs are not already arrays. + /// For zero-allocation bulk loading, prefer AddRange with ReadOnlySpan parameters. + /// public void Add(IEnumerable t, IEnumerable o, IEnumerable h, IEnumerable l, IEnumerable c, IEnumerable v) { var tArr = t as long[] ?? t.ToArray(); @@ -320,28 +325,37 @@ public class TBarSeries : IReadOnlyList if (o.Length != len || h.Length != len || l.Length != len || c.Length != len || v.Length != len) throw new ArgumentException("All spans must have the same length", nameof(t)); + if (len == 0) return; + + int oldCount = _c.Count; + int newCount = oldCount + len; + // Pre-allocate capacity to avoid repeated resizing - int newCapacity = _c.Count + len; - if (_t.Capacity < newCapacity) + if (_t.Capacity < newCount) { - _t.Capacity = newCapacity; - _o.Capacity = newCapacity; - _h.Capacity = newCapacity; - _l.Capacity = newCapacity; - _c.Capacity = newCapacity; - _v.Capacity = newCapacity; + _t.Capacity = newCount; + _o.Capacity = newCount; + _h.Capacity = newCount; + _l.Capacity = newCount; + _c.Capacity = newCount; + _v.Capacity = newCount; } - // Bulk add without event firing (for initial data loading) - for (int i = 0; i < len; i++) - { - _t.Add(t[i]); - _o.Add(o[i]); - _h.Add(h[i]); - _l.Add(l[i]); - _c.Add(c[i]); - _v.Add(v[i]); - } + // Use SetCount to resize lists without zeroing, then copy via span + CollectionsMarshal.SetCount(_t, newCount); + CollectionsMarshal.SetCount(_o, newCount); + CollectionsMarshal.SetCount(_h, newCount); + CollectionsMarshal.SetCount(_l, newCount); + CollectionsMarshal.SetCount(_c, newCount); + CollectionsMarshal.SetCount(_v, newCount); + + // Direct span copy - zero allocation bulk add + t.CopyTo(CollectionsMarshal.AsSpan(_t).Slice(oldCount)); + o.CopyTo(CollectionsMarshal.AsSpan(_o).Slice(oldCount)); + h.CopyTo(CollectionsMarshal.AsSpan(_h).Slice(oldCount)); + l.CopyTo(CollectionsMarshal.AsSpan(_l).Slice(oldCount)); + c.CopyTo(CollectionsMarshal.AsSpan(_c).Slice(oldCount)); + v.CopyTo(CollectionsMarshal.AsSpan(_v).Slice(oldCount)); } /// @@ -354,28 +368,46 @@ public class TBarSeries : IReadOnlyList int len = bars.Length; if (len == 0) return; + int oldCount = _c.Count; + int newCount = oldCount + len; + // Pre-allocate capacity to avoid repeated resizing - int newCapacity = _c.Count + len; - if (_t.Capacity < newCapacity) + if (_t.Capacity < newCount) { - _t.Capacity = newCapacity; - _o.Capacity = newCapacity; - _h.Capacity = newCapacity; - _l.Capacity = newCapacity; - _c.Capacity = newCapacity; - _v.Capacity = newCapacity; + _t.Capacity = newCount; + _o.Capacity = newCount; + _h.Capacity = newCount; + _l.Capacity = newCount; + _c.Capacity = newCount; + _v.Capacity = newCount; } - // Bulk add without event firing (for initial data loading) + // Use SetCount to resize lists without zeroing + CollectionsMarshal.SetCount(_t, newCount); + CollectionsMarshal.SetCount(_o, newCount); + CollectionsMarshal.SetCount(_h, newCount); + CollectionsMarshal.SetCount(_l, newCount); + CollectionsMarshal.SetCount(_c, newCount); + CollectionsMarshal.SetCount(_v, newCount); + + // Get mutable spans for direct write + Span tSpan = CollectionsMarshal.AsSpan(_t).Slice(oldCount); + Span oSpan = CollectionsMarshal.AsSpan(_o).Slice(oldCount); + Span hSpan = CollectionsMarshal.AsSpan(_h).Slice(oldCount); + Span lSpan = CollectionsMarshal.AsSpan(_l).Slice(oldCount); + Span cSpan = CollectionsMarshal.AsSpan(_c).Slice(oldCount); + Span vSpan = CollectionsMarshal.AsSpan(_v).Slice(oldCount); + + // Copy from TBar structs to SoA layout for (int i = 0; i < len; i++) { ref readonly TBar bar = ref bars[i]; - _t.Add(bar.Time); - _o.Add(bar.Open); - _h.Add(bar.High); - _l.Add(bar.Low); - _c.Add(bar.Close); - _v.Add(bar.Volume); + tSpan[i] = bar.Time; + oSpan[i] = bar.Open; + hSpan[i] = bar.High; + lSpan[i] = bar.Low; + cSpan[i] = bar.Close; + vSpan[i] = bar.Volume; } } diff --git a/lib/core/tvalue/TValue.Tests.cs b/lib/core/tvalue/TValue.Tests.cs index 678cc2d4..18ca6640 100644 --- a/lib/core/tvalue/TValue.Tests.cs +++ b/lib/core/tvalue/TValue.Tests.cs @@ -1,3 +1,5 @@ +using System.Globalization; + namespace QuanTAlib.Tests; public class TValueTests @@ -42,7 +44,7 @@ public class TValueTests 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(null, CultureInfo.InvariantCulture); Assert.Contains("2023-01-01", result, StringComparison.Ordinal); Assert.Contains("12:00:00", result, StringComparison.Ordinal); @@ -288,7 +290,7 @@ public class TValueTests var dt = new DateTime(2023, 1, 1, 12, 0, 0, DateTimeKind.Utc); var tValue = new TValue(dt.Ticks, double.NaN); - string result = tValue.ToString(); + string result = tValue.ToString(null, CultureInfo.InvariantCulture); Assert.Contains("NaN", result, StringComparison.Ordinal); } @@ -299,7 +301,7 @@ public class TValueTests var dt = new DateTime(2023, 1, 1, 12, 0, 0, DateTimeKind.Utc); var tValue = new TValue(dt.Ticks, double.PositiveInfinity); - string result = tValue.ToString(); + string result = tValue.ToString(null, CultureInfo.InvariantCulture); Assert.Contains("โˆž", result, StringComparison.Ordinal); } @@ -310,7 +312,7 @@ public class TValueTests 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(null, CultureInfo.InvariantCulture); Assert.Contains("-123.46", result, StringComparison.Ordinal); } @@ -340,9 +342,11 @@ public class TValueTests { var tv = new TValue(12345, double.NaN); - var hash = tv.GetHashCode(); + // Should not throw - the record struct implementation handles NaN correctly + int hash = tv.GetHashCode(); - Assert.True(hash != 0 || hash == 0); // Just verify it doesn't throw + // Hash should be consistent for same NaN value + Assert.Equal(hash, tv.GetHashCode()); } [Fact] diff --git a/lib/core/tvalue/tvalue.cs b/lib/core/tvalue/tvalue.cs index 7e32b04d..37632797 100644 --- a/lib/core/tvalue/tvalue.cs +++ b/lib/core/tvalue/tvalue.cs @@ -6,10 +6,11 @@ namespace QuanTAlib; /// /// A lightweight struct representing a time-value pair. /// Pure data type: 16 bytes (long + double). +/// Implements ISpanFormattable for allocation-free formatting. /// [SkipLocalsInit] [StructLayout(LayoutKind.Auto)] -public readonly record struct TValue(long Time, double Value) +public readonly record struct TValue(long Time, double Value) : ISpanFormattable { public DateTime AsDateTime => new(Time, DateTimeKind.Utc); @@ -37,4 +38,90 @@ public readonly record struct TValue(long Time, double Value) }; return $"[{AsDateTime:yyyy-MM-dd HH:mm:ss}, {valueStr}]"; } -} \ No newline at end of file + + /// + /// Formats the TValue using the specified format string. + /// Note: Custom format and formatProvider are not supported by TValue. + /// If a non-null/non-empty format is provided, a NotSupportedException is thrown. + /// + /// Must be null or empty; custom formats are not supported. + /// Ignored; TValue uses its own fixed format. + /// The string representation of this TValue. + /// Thrown when a non-null/non-empty format is provided. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public string ToString(string? format, IFormatProvider? formatProvider) + { + if (!string.IsNullOrEmpty(format)) + throw new NotSupportedException($"Custom format '{format}' is not supported by TValue. Use ToString() for the default format."); + + return ToString(); + } + + /// + /// Formats the TValue into the provided span without heap allocation. + /// Format: "[yyyy-MM-dd HH:mm:ss, value]" + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) + { + charsWritten = 0; + + // Early reject for buffers too small to hold even the timestamp portion. + // This is a heuristic check; actual buffer-overflow protection is performed + // by the explicit length checks that guard each write operation below. + if (destination.Length < 24) + return false; + + // Write opening bracket + destination[0] = '['; + int pos = 1; + + // Format datetime: yyyy-MM-dd HH:mm:ss (19 chars) + if (!AsDateTime.TryFormat(destination.Slice(pos), out int dtChars, "yyyy-MM-dd HH:mm:ss", provider)) + return false; + pos += dtChars; + + // Write separator + if (pos + 2 > destination.Length) + return false; + destination[pos++] = ','; + destination[pos++] = ' '; + + // Format value + if (double.IsPositiveInfinity(Value)) + { + if (pos + 1 > destination.Length) + return false; + destination[pos++] = (char)0x221E; //  + } + else if (double.IsNegativeInfinity(Value)) + { + if (pos + 2 > destination.Length) + return false; + destination[pos++] = '-'; + destination[pos++] = (char)0x221E; // - + } + else if (double.IsNaN(Value)) + { + if (pos + 3 > destination.Length) + return false; + destination[pos++] = 'N'; + destination[pos++] = 'a'; + destination[pos++] = 'N'; + } + else + { + if (!Value.TryFormat(destination.Slice(pos), out int valueChars, "F2", provider)) + return false; + pos += valueChars; + } + + // Write closing bracket + if (pos + 1 > destination.Length) + return false; + destination[pos++] = ']'; + + charsWritten = pos; + return true; + } +} diff --git a/lib/errors/huber/Huber.Tests.cs b/lib/errors/huber/Huber.Tests.cs index 308d56f6..388c6c50 100644 --- a/lib/errors/huber/Huber.Tests.cs +++ b/lib/errors/huber/Huber.Tests.cs @@ -27,7 +27,7 @@ public class HuberTests Assert.Contains("Huber", huber.Name, StringComparison.Ordinal); huber.Update(100, 105); - Assert.NotEqual(0, huber.Last.Time); + Assert.NotEqual(0, huber.Last.Value); } [Fact] @@ -403,4 +403,4 @@ public class HuberTests // With delta=5: linear region -> 5*10 - 12.5 = 37.5 Assert.NotEqual(huber1.Last.Value, huber2.Last.Value); } -} +} \ No newline at end of file diff --git a/lib/errors/mae/Mae.Tests.cs b/lib/errors/mae/Mae.Tests.cs index aca9791a..801310a3 100644 --- a/lib/errors/mae/Mae.Tests.cs +++ b/lib/errors/mae/Mae.Tests.cs @@ -22,7 +22,7 @@ public class MaeTests Assert.Contains("Mae", mae.Name, StringComparison.Ordinal); mae.Update(100, 105); - Assert.NotEqual(0, mae.Last.Time); + Assert.NotEqual(0, mae.Last.Value); } [Fact] @@ -356,4 +356,4 @@ public class MaeTests // After resync, result should still be correct Assert.Equal(10.0, mae.Last.Value, 10); } -} +} \ No newline at end of file diff --git a/lib/errors/mapd/Mapd.Tests.cs b/lib/errors/mapd/Mapd.Tests.cs index b6acd4f5..cf1a9183 100644 --- a/lib/errors/mapd/Mapd.Tests.cs +++ b/lib/errors/mapd/Mapd.Tests.cs @@ -22,7 +22,7 @@ public class MapdTests Assert.Contains("Mapd", mapd.Name, StringComparison.Ordinal); mapd.Update(100, 105); - Assert.NotEqual(0, mapd.Last.Time); + Assert.NotEqual(0, mapd.Last.Value); } [Fact] @@ -353,4 +353,4 @@ public class MapdTests var result = mapd.Update(10, 0); Assert.True(double.IsFinite(result.Value)); } -} +} \ No newline at end of file diff --git a/lib/errors/mape/Mape.Tests.cs b/lib/errors/mape/Mape.Tests.cs index 74ba9470..b0a1a8ee 100644 --- a/lib/errors/mape/Mape.Tests.cs +++ b/lib/errors/mape/Mape.Tests.cs @@ -22,7 +22,7 @@ public class MapeTests Assert.Contains("Mape", mape.Name, StringComparison.Ordinal); mape.Update(100, 105); - Assert.NotEqual(0, mape.Last.Time); + Assert.NotEqual(0, mape.Last.Value); } [Fact] @@ -386,4 +386,4 @@ public class MapeTests // Over-prediction should have higher MAPE due to smaller denominator Assert.True(overPrediction.Value > underPrediction.Value); } -} +} \ No newline at end of file diff --git a/lib/errors/me/Me.Tests.cs b/lib/errors/me/Me.Tests.cs index b520593d..f9a43bce 100644 --- a/lib/errors/me/Me.Tests.cs +++ b/lib/errors/me/Me.Tests.cs @@ -22,7 +22,7 @@ public class MeTests Assert.Contains("Me", me.Name, StringComparison.Ordinal); me.Update(100, 105); - Assert.NotEqual(0, me.Last.Time); + Assert.NotEqual(0, me.Last.Value); } [Fact] @@ -382,4 +382,4 @@ public class MeTests // After resync, result should still be correct Assert.Equal(10.0, me.Last.Value, 10); } -} +} \ No newline at end of file diff --git a/lib/errors/mrae/Mrae.Tests.cs b/lib/errors/mrae/Mrae.Tests.cs index 70e82ff3..499020b0 100644 --- a/lib/errors/mrae/Mrae.Tests.cs +++ b/lib/errors/mrae/Mrae.Tests.cs @@ -22,7 +22,7 @@ public class MraeTests Assert.Contains("Mrae", mrae.Name, StringComparison.Ordinal); mrae.Update(100, 105); - Assert.NotEqual(0, mrae.Last.Time); + Assert.NotEqual(0, mrae.Last.Value); } [Fact] @@ -330,4 +330,4 @@ public class MraeTests Assert.Equal(0.1, mrae.Last.Value, 10); } -} +} \ No newline at end of file diff --git a/lib/errors/mse/Mse.Tests.cs b/lib/errors/mse/Mse.Tests.cs index dbdd1cf6..c018b1e7 100644 --- a/lib/errors/mse/Mse.Tests.cs +++ b/lib/errors/mse/Mse.Tests.cs @@ -22,7 +22,7 @@ public class MseTests Assert.Contains("Mse", mse.Name, StringComparison.Ordinal); mse.Update(100, 105); - Assert.NotEqual(0, mse.Last.Time); + Assert.NotEqual(0, mse.Last.Value); } [Fact] @@ -308,4 +308,4 @@ public class MseTests Assert.True(double.IsFinite(val), $"Expected finite value but got {val}"); } } -} +} \ No newline at end of file diff --git a/lib/errors/rmse/Rmse.Tests.cs b/lib/errors/rmse/Rmse.Tests.cs index b8d5c55a..723907db 100644 --- a/lib/errors/rmse/Rmse.Tests.cs +++ b/lib/errors/rmse/Rmse.Tests.cs @@ -22,7 +22,7 @@ public class RmseTests Assert.Contains("Rmse", rmse.Name, StringComparison.Ordinal); rmse.Update(100, 105); - Assert.NotEqual(0, rmse.Last.Time); + Assert.NotEqual(0, rmse.Last.Value); } [Fact] @@ -247,4 +247,4 @@ public class RmseTests // All errors are 5, MSE = 25, RMSE = 5 Assert.Equal(5.0, results.Last.Value, 10); } -} +} \ No newline at end of file diff --git a/lib/feeds/csv/CsvFeed.Tests.cs b/lib/feeds/csv/CsvFeed.Tests.cs index 0719e742..36314ac0 100644 --- a/lib/feeds/csv/CsvFeed.Tests.cs +++ b/lib/feeds/csv/CsvFeed.Tests.cs @@ -26,6 +26,7 @@ public sealed class CsvFeedTests : IDisposable { if (_disposed) return; _disposed = true; + GC.SuppressFinalize(this); foreach (var file in _tempFiles) { @@ -882,4 +883,4 @@ public sealed class CsvFeedTests : IDisposable } #endregion -} +} \ No newline at end of file diff --git a/lib/feeds/gbm/Gbm.Tests.cs b/lib/feeds/gbm/Gbm.Tests.cs index e780719d..7de0a4b6 100644 --- a/lib/feeds/gbm/Gbm.Tests.cs +++ b/lib/feeds/gbm/Gbm.Tests.cs @@ -666,14 +666,33 @@ public class GBMTests [Fact] public void ImplementsIFeed() { - GBM feed = new GBM(startPrice: 100.0, seed: 42); + // Verify GBM implements IFeed interface + Assert.True(typeof(IFeed).IsAssignableFrom(typeof(GBM))); + // Use IFeed reference to verify interface contract + IFeed feed = new GBM(startPrice: 100.0, seed: 42); + + // Test Next(bool) overload via interface var bar1 = feed.Next(isNew: true); Assert.True(bar1.Time > 0); var bar2 = feed.Next(isNew: true); Assert.True(bar2.Time > bar1.Time); + // Test Next(ref bool) overload via interface - verify ref parameter behavior + bool isNew = true; + var bar3 = feed.Next(ref isNew); + Assert.True(bar3.Time > bar2.Time); + Assert.True(isNew, "GBM should honor isNew=true request and keep it true"); + + // Test with isNew=false via interface + bool isNewFalse = false; + long bar3Time = bar3.Time; + var bar3Updated = feed.Next(ref isNewFalse); + Assert.Equal(bar3Time, bar3Updated.Time); // Same bar when isNew=false + Assert.False(isNewFalse, "GBM should honor isNew=false request and keep it false"); + + // Test Fetch via interface long startTime = DateTime.UtcNow.Ticks; var series = feed.Fetch(5, startTime, TimeSpan.FromMinutes(1)); Assert.Equal(5, series.Count); diff --git a/lib/feeds/gbm/ValidationHelper.cs b/lib/feeds/gbm/ValidationHelper.cs index 31950a32..c53c1ea6 100644 --- a/lib/feeds/gbm/ValidationHelper.cs +++ b/lib/feeds/gbm/ValidationHelper.cs @@ -1,5 +1,3 @@ -using System.Runtime.CompilerServices; - namespace QuanTAlib.Tests; /// diff --git a/lib/feeds/gbm/gbm.cs b/lib/feeds/gbm/gbm.cs index cee36c56..26ec0a33 100644 --- a/lib/feeds/gbm/gbm.cs +++ b/lib/feeds/gbm/gbm.cs @@ -1,3 +1,4 @@ +using System.Buffers; using System.Runtime.CompilerServices; using System.Security.Cryptography; @@ -262,6 +263,7 @@ public sealed class GBM : IFeed /// /// Generates a batch of bars using optimized batch processing with explicit time parameters. + /// Uses stackalloc for small batches to avoid heap allocations. /// /// Number of bars to generate (must be positive) /// Starting timestamp in ticks @@ -279,14 +281,81 @@ public sealed class GBM : IFeed var series = new TBarSeries(count); - // Pre-allocate arrays for SoA layout - long[] t = new long[count]; - double[] o = new double[count]; - double[] h = new double[count]; - double[] l = new double[count]; - double[] c = new double[count]; - double[] v = new double[count]; + // Threshold for stackalloc: 64 bars * (8 bytes for long + 5*8 bytes for doubles) = 64 * 48 = 3KB + // Stay well under typical stack limit; use 64 as safe threshold + const int StackAllocThreshold = 64; + // Use stackalloc for small batches to avoid heap allocations + if (count <= StackAllocThreshold) + { + Span t = stackalloc long[count]; + Span o = stackalloc double[count]; + Span h = stackalloc double[count]; + Span l = stackalloc double[count]; + Span c = stackalloc double[count]; + Span v = stackalloc double[count]; + + FetchCore(count, startTime, interval, t, o, h, l, c, v); + + // Bulk add to series using ReadOnlySpan overload + series.AddRange(t, o, h, l, c, v); + } + else + { + // Use ArrayPool for larger batches to avoid heap allocations + long[]? rentedT = null; + double[]? rentedO = null; + double[]? rentedH = null; + double[]? rentedL = null; + double[]? rentedC = null; + double[]? rentedV = null; + + try + { + rentedT = ArrayPool.Shared.Rent(count); + rentedO = ArrayPool.Shared.Rent(count); + rentedH = ArrayPool.Shared.Rent(count); + rentedL = ArrayPool.Shared.Rent(count); + rentedC = ArrayPool.Shared.Rent(count); + rentedV = ArrayPool.Shared.Rent(count); + + // Use only the first 'count' elements (rented arrays may be larger) + var t = rentedT.AsSpan(0, count); + var o = rentedO.AsSpan(0, count); + var h = rentedH.AsSpan(0, count); + var l = rentedL.AsSpan(0, count); + var c = rentedC.AsSpan(0, count); + var v = rentedV.AsSpan(0, count); + + FetchCore(count, startTime, interval, t, o, h, l, c, v); + + // Bulk add to series using ReadOnlySpan overload + series.AddRange(t, o, h, l, c, v); + } + finally + { + if (rentedT != null) ArrayPool.Shared.Return(rentedT); + if (rentedO != null) ArrayPool.Shared.Return(rentedO); + if (rentedH != null) ArrayPool.Shared.Return(rentedH); + if (rentedL != null) ArrayPool.Shared.Return(rentedL); + if (rentedC != null) ArrayPool.Shared.Return(rentedC); + if (rentedV != null) ArrayPool.Shared.Return(rentedV); + } + } + + // Reset streaming state after batch + _hasCurrentBar = false; + + return series; + } + + /// + /// Core generation logic shared between stackalloc and heap-allocated paths. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void FetchCore(int count, long startTime, TimeSpan interval, + Span t, Span o, Span h, Span l, Span c, Span v) + { const double minutesPerYear = 252.0 * 6.5 * 60.0; double dt = interval.TotalMinutes / minutesPerYear; double drift = (Mu - 0.5 * Sigma * Sigma) * dt; @@ -335,14 +404,6 @@ public sealed class GBM : IFeed // Update internal state to continue from end of batch _lastPrice = currentPrice; _lastTime = currentTime - timeStep; // Last bar time, not next bar time - - // Bulk add to series - series.Add(t, o, h, l, c, v); - - // Reset streaming state after batch - _hasCurrentBar = false; - - return series; } } -#pragma warning restore S2245 \ No newline at end of file +#pragma warning restore S2245 diff --git a/lib/numerics/accel/Accel.Quantower.cs b/lib/numerics/accel/Accel.Quantower.cs index 6d50a789..e9dc7a93 100644 --- a/lib/numerics/accel/Accel.Quantower.cs +++ b/lib/numerics/accel/Accel.Quantower.cs @@ -1,4 +1,5 @@ using System.Drawing; +using System.Runtime.CompilerServices; using TradingPlatform.BusinessLayer; using static QuanTAlib.IndicatorExtensions; @@ -19,6 +20,11 @@ public class AccelIndicator : Indicator, IWatchlistIndicator private Accel? _accel; private Func? _selector; + // Cached markers to avoid per-update allocations + private static readonly IndicatorLineMarker GreenMarker = new(Color.Green); + private static readonly IndicatorLineMarker RedMarker = new(Color.Red); + private static readonly IndicatorLineMarker GrayMarker = new(Color.Gray); + public int MinHistoryDepths => 3; public override string ShortName => "ACCEL"; @@ -47,25 +53,42 @@ public class AccelIndicator : Indicator, IWatchlistIndicator double value = _selector(item); bool isNew = args.IsNewBar(); - TValue input = new(item.TimeLeft, value); - _accel.Update(input, isNew); + ProcessUpdateCore(item.TimeLeft, value, isNew); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void ProcessUpdateCore(DateTime time, double value, bool isNew) + { + // Validate non-finite inputs - use last valid if not finite + if (!double.IsFinite(value)) + { + value = _accel!.Last.Value; + if (!double.IsFinite(value)) + value = 0.0; + } + + TValue input = new(time, value); + _accel!.Update(input, isNew); bool isHot = _accel.IsHot; + double accelValue = _accel.Last.Value; // Cache to avoid repeated property access - LinesSeries[0].SetValue(_accel.Last.Value, isHot, ShowColdValues); + LinesSeries[0].SetValue(accelValue, isHot, ShowColdValues); LinesSeries[1].SetValue(0); if (isHot || ShowColdValues) { - double accel = _accel.Last.Value; - Color color; - if (accel > 0) - color = Color.Green; - else if (accel < 0) - color = Color.Red; - else - color = Color.Gray; - LinesSeries[0].SetMarker(0, new IndicatorLineMarker(color)); + // Use cached markers to avoid per-update allocations + IndicatorLineMarker marker = GetMarker(accelValue); + LinesSeries[0].SetMarker(0, marker); } } -} + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static IndicatorLineMarker GetMarker(double value) + { + if (value > 0) return GreenMarker; + if (value < 0) return RedMarker; + return GrayMarker; + } +} \ No newline at end of file diff --git a/lib/numerics/accel/Accel.cs b/lib/numerics/accel/Accel.cs index b696a1e0..ac3811a2 100644 --- a/lib/numerics/accel/Accel.cs +++ b/lib/numerics/accel/Accel.cs @@ -171,9 +171,14 @@ public sealed class Accel : AbstractBase public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { - foreach (double val in source) + // TValue is a readonly record struct - no heap allocation occurs + TimeSpan interval = step ?? TimeSpan.FromSeconds(1); + DateTime time = DateTime.UtcNow - (interval * source.Length); + + for (int i = 0; i < source.Length; i++) { - Update(new TValue(DateTime.MinValue, val)); + Update(new TValue(time, source[i]), true); + time += interval; } } @@ -300,4 +305,4 @@ public sealed class Accel : AbstractBase if (double.IsFinite(c)) return c; return 0.0; } -} \ No newline at end of file +} diff --git a/lib/numerics/change/Change.Quantower.cs b/lib/numerics/change/Change.Quantower.cs index ae105492..d54fd594 100644 --- a/lib/numerics/change/Change.Quantower.cs +++ b/lib/numerics/change/Change.Quantower.cs @@ -23,6 +23,11 @@ public class ChangeIndicator : Indicator, IWatchlistIndicator private Change? _change; private Func? _selector; + // Cached markers to avoid per-update allocations + private static readonly IndicatorLineMarker GreenMarker = new(Color.Green); + private static readonly IndicatorLineMarker RedMarker = new(Color.Red); + private static readonly IndicatorLineMarker GrayMarker = new(Color.Gray); + public int MinHistoryDepths => Period + 1; public override string ShortName => $"CHANGE({Period})"; @@ -55,21 +60,25 @@ public class ChangeIndicator : Indicator, IWatchlistIndicator _change.Update(input, isNew); bool isHot = _change.IsHot; + double changeValue = _change.Last.Value; // Cache to avoid repeated property access - LinesSeries[0].SetValue(_change.Last.Value, isHot, ShowColdValues); + LinesSeries[0].SetValue(changeValue, isHot, ShowColdValues); LinesSeries[1].SetValue(0); if (isHot || ShowColdValues) { - double change = _change.Last.Value; - Color color; - if (change > 0) - color = Color.Green; - else if (change < 0) - color = Color.Red; - else - color = Color.Gray; - LinesSeries[0].SetMarker(0, new IndicatorLineMarker(color)); + // Use cached markers to avoid per-update allocations + IndicatorLineMarker marker = GetMarker(changeValue); + LinesSeries[0].SetMarker(0, marker); } } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static IndicatorLineMarker GetMarker(double value) + { + if (!double.IsFinite(value)) return GrayMarker; + if (value > 0) return GreenMarker; + if (value < 0) return RedMarker; + return GrayMarker; + } } diff --git a/lib/numerics/change/Change.Tests.cs b/lib/numerics/change/Change.Tests.cs index 197edb41..7bd073ce 100644 --- a/lib/numerics/change/Change.Tests.cs +++ b/lib/numerics/change/Change.Tests.cs @@ -162,10 +162,16 @@ public class ChangeTests indicator.Update(_source[i]); } - // Compare last 10 values + // Compare last 10 values between batch and streaming + var streamResult = new TSeries(); + for (int j = 0; j < _source.Count; j++) + { + streamResult.Add(indicator.Update(_source[j]), true); + } + for (int i = Math.Max(0, _source.Count - 10); i < _source.Count; i++) { - Assert.Equal(batchResult[i].Value, batchResult[i].Value, 1e-10); + Assert.Equal(batchResult[i].Value, streamResult[i].Value, 1e-10); } // Ensure final values match @@ -214,4 +220,4 @@ public class ChangeTests Assert.True(change.IsHot); Assert.NotEqual(0.0, change.Last.Value); } -} +} \ No newline at end of file diff --git a/lib/numerics/exptrans/Exptrans.Quantower.Tests.cs b/lib/numerics/exptrans/Exptrans.Quantower.Tests.cs index 3907b9f0..67f30a73 100644 --- a/lib/numerics/exptrans/Exptrans.Quantower.Tests.cs +++ b/lib/numerics/exptrans/Exptrans.Quantower.Tests.cs @@ -116,4 +116,76 @@ public class ExptransIndicatorTests Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0))); } } -} \ No newline at end of file + + [Fact] + public void ExptransIndicator_NaNInput_ProducesFiniteOutput() + { + var indicator = new ExptransIndicator(); + indicator.Initialize(); + + var now = DateTime.UtcNow; + // First add a valid bar to establish last valid value + indicator.HistoricalData.AddBar(now, 1, 2, 0, 1); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + + // Add bar with NaN close - should use last valid value (1), so exp(1) = e + indicator.HistoricalData.AddBar(now.AddMinutes(1), double.NaN, double.NaN, double.NaN, double.NaN); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar)); + + Assert.Equal(2, indicator.LinesSeries[0].Count); + Assert.Equal(Math.E, indicator.LinesSeries[0].GetValue(0), 1e-10); + } + + [Fact] + public void ExptransIndicator_InfinityInput_ProducesFiniteOutput() + { + var indicator = new ExptransIndicator(); + indicator.Initialize(); + + var now = DateTime.UtcNow; + // First add a valid bar to establish last valid value + indicator.HistoricalData.AddBar(now, 1, 2, 0, 1); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + + // Add bar with Infinity close - should use last valid value (1), so exp(1) = e + indicator.HistoricalData.AddBar(now.AddMinutes(1), double.PositiveInfinity, double.PositiveInfinity, double.NegativeInfinity, double.PositiveInfinity); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar)); + + Assert.Equal(2, indicator.LinesSeries[0].Count); + Assert.Equal(Math.E, indicator.LinesSeries[0].GetValue(0), 1e-10); + } + + [Fact] + public void ExptransIndicator_NewTick_UpdatesSameBar() + { + var indicator = new ExptransIndicator(); + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 0, 1, -1, 0); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + double firstValue = indicator.LinesSeries[0].GetValue(0); + + // NewTick should recalculate the same bar + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick)); + + // Value should remain consistent (exp(0) = 1) + Assert.Equal(1.0, indicator.LinesSeries[0].GetValue(0), 1e-10); + Assert.Equal(firstValue, indicator.LinesSeries[0].GetValue(0), 1e-10); + } + + [Fact] + public void ExptransIndicator_KnownValues_ComputesCorrectly() + { + var indicator = new ExptransIndicator(); + indicator.Initialize(); + + var now = DateTime.UtcNow; + // exp(2) โ‰ˆ 7.389 + indicator.HistoricalData.AddBar(now, 2, 3, 1, 2); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + + Assert.Equal(Math.Exp(2), indicator.LinesSeries[0].GetValue(0), 1e-10); + } +} diff --git a/lib/numerics/exptrans/Exptrans.cs b/lib/numerics/exptrans/Exptrans.cs index b3cd2bb9..a95232a1 100644 --- a/lib/numerics/exptrans/Exptrans.cs +++ b/lib/numerics/exptrans/Exptrans.cs @@ -2,9 +2,6 @@ // Transforms values using the exponential function e^x using System.Runtime.CompilerServices; -using System.Numerics; -using System.Runtime.Intrinsics; -using System.Runtime.Intrinsics.X86; namespace QuanTAlib; diff --git a/lib/numerics/highest/Highest.Tests.cs b/lib/numerics/highest/Highest.Tests.cs index ee4cc9d8..7de69d80 100644 --- a/lib/numerics/highest/Highest.Tests.cs +++ b/lib/numerics/highest/Highest.Tests.cs @@ -129,7 +129,8 @@ public class HighestTests indicator.Update(new TValue(time, 15.0)); indicator.Update(new TValue(time.AddMinutes(1), double.PositiveInfinity)); - Assert.True(double.IsFinite(indicator.Last.Value)); + // Should use last valid value (15.0) instead of infinity + Assert.Equal(15.0, indicator.Last.Value, Tolerance); } [Fact] @@ -296,4 +297,4 @@ public class HighestTests indicator.Update(new TValue(time.AddMinutes(5), 5.0)); Assert.Equal(9.0, indicator.Last.Value, Tolerance); } -} +} \ No newline at end of file diff --git a/lib/numerics/highest/Highest.cs b/lib/numerics/highest/Highest.cs index ddb81bcc..824940e7 100644 --- a/lib/numerics/highest/Highest.cs +++ b/lib/numerics/highest/Highest.cs @@ -148,33 +148,48 @@ public sealed class Highest : AbstractBase } // Second pass: compute rolling max using corrected values - int dequeStart = 0; - int dequeEnd = 0; + // Use circular buffer indexing to avoid compaction overhead + // Branch-based wrapping is faster than modulo in hot paths + int head = 0; // front of deque (oldest/max) + int tail = 0; // back of deque (newest) + int count = 0; // number of elements in deque + int capacity = deque.Length; for (int i = 0; i < len; i++) { double value = values[i]; - // Remove indices outside window - while (dequeEnd > dequeStart && deque[dequeStart] <= i - period) - dequeStart++; - - // Remove smaller values from back - while (dequeEnd > dequeStart && values[deque[dequeEnd - 1]] <= value) - dequeEnd--; - - // Compact deque if needed - if (dequeEnd >= deque.Length) + // Remove indices outside window from front + while (count > 0 && deque[head] <= i - period) { - int count = dequeEnd - dequeStart; - for (int j = 0; j < count; j++) - deque[j] = deque[dequeStart + j]; - dequeStart = 0; - dequeEnd = count; + head++; + if (head >= capacity) head -= capacity; + count--; } - deque[dequeEnd++] = i; - output[i] = values[deque[dequeStart]]; + // Remove smaller values from back + while (count > 0) + { + int backIdx = tail - 1; + if (backIdx < 0) backIdx += capacity; + if (values[deque[backIdx]] <= value) + { + tail = backIdx; + count--; + } + else + { + break; + } + } + + // Add current index at tail + deque[tail] = i; + tail++; + if (tail >= capacity) tail -= capacity; + count++; + + output[i] = values[deque[head]]; } } finally @@ -193,4 +208,4 @@ public sealed class Highest : AbstractBase _p_state = default; Last = default; } -} \ No newline at end of file +} diff --git a/lib/numerics/jerk/Jerk.Quantower.Tests.cs b/lib/numerics/jerk/Jerk.Quantower.Tests.cs index c23c1127..883dfd9e 100644 --- a/lib/numerics/jerk/Jerk.Quantower.Tests.cs +++ b/lib/numerics/jerk/Jerk.Quantower.Tests.cs @@ -184,7 +184,8 @@ public class JerkIndicatorTests var now = DateTime.UtcNow; - // Cubic trend: changing acceleration = non-zero jerk + // Cubic trend: f(x) = xณ has third derivative = 6 + // Using f(i) = iณ, the discrete third differences converge to 6 for (int i = 0; i < 10; i++) { double price = 100 + i * i * i; // cubic growth @@ -193,7 +194,8 @@ public class JerkIndicatorTests } double lastJerk = indicator.LinesSeries[0].GetValue(0); - Assert.True(lastJerk != 0); + // For f(x) = xณ, discrete third difference = 6 + Assert.Equal(6.0, lastJerk, 6); } [Fact] @@ -215,4 +217,4 @@ public class JerkIndicatorTests double lastJerk = indicator.LinesSeries[0].GetValue(0); Assert.Equal(0, lastJerk, 6); } -} +} \ No newline at end of file diff --git a/lib/numerics/jerk/Jerk.Tests.cs b/lib/numerics/jerk/Jerk.Tests.cs index 8a1d9e07..5e9ffb4c 100644 --- a/lib/numerics/jerk/Jerk.Tests.cs +++ b/lib/numerics/jerk/Jerk.Tests.cs @@ -1,3 +1,5 @@ +using Xunit; + namespace QuanTAlib.Tests; public class JerkTests @@ -302,4 +304,4 @@ public class JerkTests Assert.Equal(jerkResults[i], chainResults[i], precision: 9); } } -} +} \ No newline at end of file diff --git a/lib/numerics/lineartrans/Lineartrans.Quantower.Tests.cs b/lib/numerics/lineartrans/Lineartrans.Quantower.Tests.cs index 2baf8714..f4226699 100644 --- a/lib/numerics/lineartrans/Lineartrans.Quantower.Tests.cs +++ b/lib/numerics/lineartrans/Lineartrans.Quantower.Tests.cs @@ -80,7 +80,7 @@ public class LineartransIndicatorTests [Fact] public void LineartransIndicator_ProcessUpdate_NewTick_ProcessesWithoutError() { - var indicator = new LineartransIndicator(); + var indicator = new LineartransIndicator { Slope = 2.0, Intercept = 5.0 }; indicator.Initialize(); var now = DateTime.UtcNow; @@ -90,6 +90,8 @@ public class LineartransIndicatorTests indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick)); Assert.Equal(2, indicator.LinesSeries[0].Count); + // NewTick recalculates same bar: 2 * 100 + 5 = 205 + Assert.Equal(205.0, indicator.LinesSeries[0].GetValue(0), 1e-10); } [Fact] diff --git a/lib/numerics/lineartrans/Lineartrans.cs b/lib/numerics/lineartrans/Lineartrans.cs index 0b6d4629..0a6fc988 100644 --- a/lib/numerics/lineartrans/Lineartrans.cs +++ b/lib/numerics/lineartrans/Lineartrans.cs @@ -4,6 +4,7 @@ using System.Runtime.CompilerServices; using System.Runtime.Intrinsics; using System.Runtime.Intrinsics.X86; +using System.Runtime.Intrinsics.Arm; namespace QuanTAlib; @@ -119,6 +120,7 @@ public sealed class Lineartrans : AbstractBase /// /// Calculates linear transformation over a span of values using SIMD when available. + /// Uses FMA intrinsics for y = slope * x + intercept. /// public static void Calculate(ReadOnlySpan source, Span output, double slope = 1.0, double intercept = 0.0) @@ -132,34 +134,84 @@ public sealed class Lineartrans : AbstractBase if (!double.IsFinite(intercept)) throw new ArgumentException("Intercept must be a finite number", nameof(intercept)); + // Check for non-finite values - if any exist, use scalar path only + // Note: For very large arrays, SIMD-based NaN detection could be faster, + // but for typical use cases the scalar pre-scan is sufficient + bool hasNonFinite = false; + for (int k = 0; k < source.Length && !hasNonFinite; k++) + { + hasNonFinite = !double.IsFinite(source[k]); + } + double lastValid = 0.0; int i = 0; - // SIMD path for AVX2 (process 4 doubles at a time) - if (Avx2.IsSupported && source.Length >= Vector256.Count) + // AVX512 FMA path (8 doubles at once) + // Avx512F.FusedMultiplyAdd is independent of Fma.IsSupported + if (!hasNonFinite && Avx512F.IsSupported && source.Length >= 8) { - int vectorLength = source.Length - (source.Length % Vector256.Count); + var slopeVec = Vector512.Create(slope); + var interceptVec = Vector512.Create(intercept); + int simdEnd = source.Length - (source.Length % 8); - for (; i < vectorLength; i += Vector256.Count) + for (; i < simdEnd; i += 8) { - // Check for finite values and handle last-valid - for (int j = 0; j < Vector256.Count; j++) - { - double val = source[i + j]; - if (double.IsFinite(val)) - { - lastValid = Math.FusedMultiplyAdd(slope, val, intercept); - output[i + j] = lastValid; - } - else - { - output[i + j] = lastValid; - } - } + var vals = Vector512.Create(source.Slice(i, 8)); + var result = Avx512F.FusedMultiplyAdd(slopeVec, vals, interceptVec); + result.CopyTo(output.Slice(i, 8)); } + lastValid = output[simdEnd - 1]; + } + // AVX2 FMA path (4 doubles at once) + else if (!hasNonFinite && Fma.IsSupported && source.Length >= 4) + { + var slopeVec = Vector256.Create(slope); + var interceptVec = Vector256.Create(intercept); + int simdEnd = source.Length - (source.Length % 4); + + for (; i < simdEnd; i += 4) + { + var vals = Vector256.Create(source.Slice(i, 4)); + var result = Fma.MultiplyAdd(slopeVec, vals, interceptVec); + result.CopyTo(output.Slice(i, 4)); + } + lastValid = output[simdEnd - 1]; + } + // SSE2 path (2 doubles at once) - fallback for x86/x64 without FMA + // Note: This path uses Sse2.Multiply followed by Sse2.Add, which incurs two rounding + // steps unlike the FMA paths above. Results may differ by ~1 ULP compared to FMA + // on SSE2-only hardware (e.g., older x86/x64 CPUs without AVX2/FMA support). + else if (!hasNonFinite && Sse2.IsSupported && source.Length >= 2) + { + var slopeVec = Vector128.Create(slope); + var interceptVec = Vector128.Create(intercept); + int simdEnd = source.Length - (source.Length % 2); + + for (; i < simdEnd; i += 2) + { + var vals = Vector128.Create(source.Slice(i, 2)); + var result = Sse2.Add(Sse2.Multiply(slopeVec, vals), interceptVec); + result.CopyTo(output.Slice(i, 2)); + } + lastValid = output[simdEnd - 1]; + } + // ARM64 NEON FMA path (2 doubles at once) + else if (!hasNonFinite && AdvSimd.Arm64.IsSupported && source.Length >= 2) + { + var slopeVec = Vector128.Create(slope); + var interceptVec = Vector128.Create(intercept); + int simdEnd = source.Length - (source.Length % 2); + + for (; i < simdEnd; i += 2) + { + var vals = Vector128.Create(source.Slice(i, 2)); + var result = AdvSimd.Arm64.FusedMultiplyAdd(interceptVec, vals, slopeVec); + result.CopyTo(output.Slice(i, 2)); + } + lastValid = output[simdEnd - 1]; } - // Scalar fallback for remaining elements + // Scalar fallback for remaining elements or when non-finite values exist for (; i < source.Length; i++) { double val = source[i]; @@ -181,4 +233,4 @@ public sealed class Lineartrans : AbstractBase _p_state = default; Last = default; } -} \ No newline at end of file +} diff --git a/lib/numerics/logtrans/Logtrans.Validation.Tests.cs b/lib/numerics/logtrans/Logtrans.Validation.Tests.cs index 1136074c..9a51881b 100644 --- a/lib/numerics/logtrans/Logtrans.Validation.Tests.cs +++ b/lib/numerics/logtrans/Logtrans.Validation.Tests.cs @@ -87,27 +87,37 @@ public class LogtransValidationTests } [Fact] - public void Logtrans_ProductRule() + public void Logtrans_ZeroInput_UsesLastValid() { - // ln(a*b) = ln(a) + ln(b) - double a = 2.5; - double b = 3.7; - + // Zero input uses last valid value (robustness pattern) var indicator = new Logtrans(); var time = DateTime.UtcNow; - indicator.Update(new TValue(time, a)); - double lnA = indicator.Last.Value; + // First update with valid value + indicator.Update(new TValue(time, Math.E)); + double lastValid = indicator.Last.Value; // ln(e) = 1.0 - indicator.Reset(); - indicator.Update(new TValue(time, b)); - double lnB = indicator.Last.Value; + // Zero input - should use last valid + indicator.Update(new TValue(time.AddMinutes(1), 0.0)); - indicator.Reset(); - indicator.Update(new TValue(time, a * b)); - double lnAB = indicator.Last.Value; + Assert.Equal(lastValid, indicator.Last.Value, Tolerance); + } - Assert.Equal(lnA + lnB, lnAB, Tolerance); + [Fact] + public void Logtrans_NegativeInput_UsesLastValid() + { + // Negative input uses last valid value (robustness pattern) + var indicator = new Logtrans(); + var time = DateTime.UtcNow; + + // First update with valid value + indicator.Update(new TValue(time, 2.0)); + double lastValid = indicator.Last.Value; // ln(2) + + // Negative input - should use last valid + indicator.Update(new TValue(time.AddMinutes(1), -1.0)); + + Assert.Equal(lastValid, indicator.Last.Value, Tolerance); } [Fact] @@ -153,4 +163,111 @@ public class LogtransValidationTests Assert.Equal(n * lnA, lnAPowN, Tolerance); } -} \ No newline at end of file + + [Fact] + public void Logtrans_VerySmallPositive_ApproachesNegativeInfinity() + { + // ln(ฮต) โ†’ -โˆž as ฮต โ†’ 0+ + var indicator = new Logtrans(); + var time = DateTime.UtcNow; + + indicator.Update(new TValue(time, double.Epsilon)); + double result = indicator.Last.Value; + + Assert.True(double.IsFinite(result)); + Assert.True(result < -700); // ln(double.Epsilon) โ‰ˆ -744 + } + + [Fact] + public void Logtrans_VeryLargeValue_Handles() + { + // ln(large) should be finite + var indicator = new Logtrans(); + var time = DateTime.UtcNow; + + indicator.Update(new TValue(time, 1e300)); + double result = indicator.Last.Value; + + Assert.True(double.IsFinite(result)); + Assert.Equal(Math.Log(1e300), result, Tolerance); + } + + [Fact] + public void Logtrans_Span_ZeroInput_UsesLastValid() + { + // Span API: zero input uses last valid value (robustness pattern) + var values = new double[] { 2.0, 0.0, 3.0 }; + var output = new double[3]; + + Logtrans.Calculate(values, output); + + Assert.Equal(Math.Log(2.0), output[0], Tolerance); // ln(2) + Assert.Equal(Math.Log(2.0), output[1], Tolerance); // zero -> uses last valid (ln(2)) + Assert.Equal(Math.Log(3.0), output[2], Tolerance); // ln(3) + } + + [Fact] + public void Logtrans_Span_NegativeInput_UsesLastValid() + { + // Span API: negative input uses last valid value (robustness pattern) + var values = new double[] { 2.0, -5.0, 3.0 }; + var output = new double[3]; + + Logtrans.Calculate(values, output); + + Assert.Equal(Math.Log(2.0), output[0], Tolerance); // ln(2) + Assert.Equal(Math.Log(2.0), output[1], Tolerance); // negative -> uses last valid (ln(2)) + Assert.Equal(Math.Log(3.0), output[2], Tolerance); // ln(3) + } + + [Fact] + public void Logtrans_NaNInput_UsesLastValid() + { + // NaN input uses last valid value (robustness pattern) + var indicator = new Logtrans(); + var time = DateTime.UtcNow; + + // First update with valid value + indicator.Update(new TValue(time, Math.E)); + double lastValid = indicator.Last.Value; // ln(e) = 1.0 + + // NaN input - should use last valid + indicator.Update(new TValue(time.AddMinutes(1), double.NaN)); + + Assert.Equal(lastValid, indicator.Last.Value, Tolerance); + } + + [Fact] + public void Logtrans_PositiveInfinityInput_UsesLastValid() + { + // Positive infinity input uses last valid value (robustness pattern) + var indicator = new Logtrans(); + var time = DateTime.UtcNow; + + // First update with valid value + indicator.Update(new TValue(time, 10.0)); + double lastValid = indicator.Last.Value; // ln(10) + + // Positive infinity input - should use last valid + indicator.Update(new TValue(time.AddMinutes(1), double.PositiveInfinity)); + + Assert.Equal(lastValid, indicator.Last.Value, Tolerance); + } + + [Fact] + public void Logtrans_NegativeInfinityInput_UsesLastValid() + { + // Negative infinity input uses last valid value (robustness pattern) + var indicator = new Logtrans(); + var time = DateTime.UtcNow; + + // First update with valid value + indicator.Update(new TValue(time, 5.0)); + double lastValid = indicator.Last.Value; // ln(5) + + // Negative infinity input - should use last valid + indicator.Update(new TValue(time.AddMinutes(1), double.NegativeInfinity)); + + Assert.Equal(lastValid, indicator.Last.Value, Tolerance); + } +} diff --git a/lib/numerics/logtrans/Logtrans.cs b/lib/numerics/logtrans/Logtrans.cs index 2b07c06d..e280b53f 100644 --- a/lib/numerics/logtrans/Logtrans.cs +++ b/lib/numerics/logtrans/Logtrans.cs @@ -2,9 +2,6 @@ // Transforms values using natural logarithm (base e) using System.Runtime.CompilerServices; -using System.Numerics; -using System.Runtime.Intrinsics; -using System.Runtime.Intrinsics.X86; namespace QuanTAlib; @@ -102,7 +99,8 @@ public sealed class Logtrans : AbstractBase } /// - /// Calculates natural logarithm over a span of values using SIMD when available. + /// Calculates natural logarithm over a span of values. + /// Note: Math.Log has no SIMD intrinsic; uses scalar path with last-valid substitution. /// public static void Calculate(ReadOnlySpan source, Span output) { @@ -112,34 +110,8 @@ public sealed class Logtrans : AbstractBase throw new ArgumentException("Output length must be >= source length", nameof(output)); double lastValid = 0.0; - int i = 0; - // SIMD path for AVX2 (process 4 doubles at a time) - if (Avx2.IsSupported && source.Length >= Vector256.Count) - { - int vectorLength = source.Length - (source.Length % Vector256.Count); - - for (; i < vectorLength; i += Vector256.Count) - { - // Process scalar for proper last-valid handling (Logtrans has no SIMD intrinsic) - for (int j = 0; j < Vector256.Count; j++) - { - double val = source[i + j]; - if (double.IsFinite(val) && val > 0) - { - lastValid = Math.Log(val); - output[i + j] = lastValid; - } - else - { - output[i + j] = lastValid; - } - } - } - } - - // Scalar fallback for remaining elements - for (; i < source.Length; i++) + for (int i = 0; i < source.Length; i++) { double val = source[i]; if (double.IsFinite(val) && val > 0)