diff --git a/lib/averages/ema/Ema.Quantower.cs b/lib/averages/ema/Ema.Quantower.cs index cb415efb..34565fff 100644 --- a/lib/averages/ema/Ema.Quantower.cs +++ b/lib/averages/ema/Ema.Quantower.cs @@ -17,6 +17,7 @@ public class EmaIndicator : Indicator, IWatchlistIndicator private Ema? ma; protected LineSeries? Series; protected string? SourceName; + private int _warmupBarIndex = -1; public int MinHistoryDepths => Period; int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths; @@ -38,6 +39,7 @@ public class EmaIndicator : Indicator, IWatchlistIndicator { ma = new Ema(Period); SourceName = Source.ToString(); + _warmupBarIndex = -1; // Reset warmup tracking when period changes base.OnInit(); } @@ -48,11 +50,16 @@ public class EmaIndicator : Indicator, IWatchlistIndicator TValue result = ma!.Update(input, isNew); Series!.SetValue(result.Value); Series!.SetMarker(0, Color.Transparent); //OnPaintChart draws the line, hidden here + + // Track when IsHot becomes true for the first time + if (_warmupBarIndex < 0 && ma!.IsHot) + _warmupBarIndex = Count; } public override void OnPaintChart(PaintChartEventArgs args) { base.OnPaintChart(args); - this.PaintSmoothCurve(args, Series!, 0, showColdValues: ShowColdValues, tension: 0.2); + int warmupPeriod = _warmupBarIndex > 0 ? _warmupBarIndex : Count; + this.PaintSmoothCurve(args, Series!, warmupPeriod, showColdValues: ShowColdValues, tension: 0.2); } } diff --git a/lib/averages/ema/Ema.Tests.cs b/lib/averages/ema/Ema.Tests.cs index 56d9b439..c1c1b41f 100644 --- a/lib/averages/ema/Ema.Tests.cs +++ b/lib/averages/ema/Ema.Tests.cs @@ -100,22 +100,17 @@ public class EmaTests } [Fact] - public void Ema_IsHot_BecomesTrueAfterWarmup() + public void Ema_IsHot_BecomesTrueAt95PercentCoverage() { var ema = new Ema(10); // Initially IsHot should be false Assert.False(ema.IsHot); - // Feed values until it warms up - // Warmup condition is state.E <= 1e-10 - // state.E starts at 1.0 and decays by (1 - alpha) each step - // alpha = 2 / (10 + 1) = 2/11 ~= 0.1818 - // (1 - alpha) ~= 0.8181 - // 1.0 * (0.8181)^n <= 1e-10 - // n * log(0.8181) <= log(1e-10) - // n * -0.200 <= -23.02 - // n >= 115 steps roughly + // IsHot triggers at 95% coverage (E <= 0.05) + // E = (1 - alpha)^N where alpha = 2 / (period + 1) + // For period 10: alpha = 2/11 ≈ 0.1818, (1-alpha) ≈ 0.8182 + // N = ln(0.05) / ln(0.8182) ≈ 14.93, so ~15 bars int steps = 0; while (!ema.IsHot && steps < 1000) @@ -125,7 +120,46 @@ public class EmaTests } Assert.True(ema.IsHot); - Assert.True(steps > 0); // Should take some steps + Assert.True(steps > 0); + // For period 10, should become hot around 15 bars + Assert.InRange(steps, 14, 16); + } + + [Fact] + public void Ema_IsHot_IsPeriodDependent() + { + // Test that different periods result in different warmup times + // Formula: N = ln(0.05) / ln((p-1)/(p+1)) + + int[] periods = [10, 20, 50, 100]; + int[] expectedSteps = new int[periods.Length]; + + for (int i = 0; i < periods.Length; i++) + { + int period = periods[i]; + var ema = new Ema(period); + + int steps = 0; + while (!ema.IsHot && steps < 500) + { + ema.Update(new TValue(DateTime.UtcNow, 100)); + steps++; + } + + expectedSteps[i] = steps; + } + + // Verify warmup times increase with period + // Period 10 → ~15 bars, Period 20 → ~30 bars, Period 50 → ~75 bars, Period 100 → ~150 bars + Assert.True(expectedSteps[0] < expectedSteps[1], $"Period 10 ({expectedSteps[0]}) should be less than Period 20 ({expectedSteps[1]})"); + Assert.True(expectedSteps[1] < expectedSteps[2], $"Period 20 ({expectedSteps[1]}) should be less than Period 50 ({expectedSteps[2]})"); + Assert.True(expectedSteps[2] < expectedSteps[3], $"Period 50 ({expectedSteps[2]}) should be less than Period 100 ({expectedSteps[3]})"); + + // Verify approximate expected values (N ≈ 1.5 * period for 95% coverage) + Assert.InRange(expectedSteps[0], 14, 17); // Period 10 → ~15 + Assert.InRange(expectedSteps[1], 28, 32); // Period 20 → ~30 + Assert.InRange(expectedSteps[2], 73, 78); // Period 50 → ~75 + Assert.InRange(expectedSteps[3], 147, 153); // Period 100 → ~150 } [Fact] diff --git a/lib/averages/ema/Ema.cs b/lib/averages/ema/Ema.cs index 8b12123e..a86dbb2d 100644 --- a/lib/averages/ema/Ema.cs +++ b/lib/averages/ema/Ema.cs @@ -28,19 +28,20 @@ public class Ema private struct State : IEquatable { public double Ema; - public double E; - public bool IsHot; + public double E; // Compensator: decays from 1.0 to 1e-10 for bias correction + public bool IsHot; // True when 95% coverage reached (E <= 0.05) + public bool IsCompensated; // True when compensator fully decayed (E <= 1e-10) - public static State New() => new() { Ema = 0, E = 1.0, IsHot = false }; + public static State New() => new() { Ema = 0, E = 1.0, IsHot = false, IsCompensated = false }; public readonly bool Equals(State other) => - Ema == other.Ema && E == other.E && IsHot == other.IsHot; + Ema == other.Ema && E == other.E && IsHot == other.IsHot && IsCompensated == other.IsCompensated; public override readonly bool Equals(object? obj) => obj is State other && Equals(other); public override readonly int GetHashCode() => - HashCode.Combine(Ema, E, IsHot); + HashCode.Combine(Ema, E, IsHot, IsCompensated); public static bool operator ==(State left, State right) => left.Equals(right); public static bool operator !=(State left, State right) => !left.Equals(right); @@ -107,9 +108,16 @@ public class Ema return _lastValidValue; } + // 95% coverage threshold: E = 1 - 0.95 = 0.05 + private const double COVERAGE_THRESHOLD = 0.05; + // Compensator decay threshold for bias correction + private const double COMPENSATOR_THRESHOLD = 1e-10; + /// /// Core EMA calculation kernel. /// Assumes input has already been validated via GetValidValue(). + /// IsHot becomes true at 95% coverage (E <= 0.05). + /// Bias correction continues until compensator decays to 1e-10. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private static double Compute(double input, double alpha, ref State state) @@ -117,11 +125,24 @@ public class Ema state.Ema += alpha * (input - state.Ema); double result; - if (!state.IsHot) + if (!state.IsCompensated) { state.E *= (1.0 - alpha); - state.IsHot = state.E <= 1e-10; - result = state.Ema / (1.0 - state.E); + + // IsHot triggers at 95% coverage + if (!state.IsHot && state.E <= COVERAGE_THRESHOLD) + state.IsHot = true; + + // Continue bias correction until compensator fully decays + if (state.E <= COMPENSATOR_THRESHOLD) + { + state.IsCompensated = true; + result = state.Ema; + } + else + { + result = state.Ema / (1.0 - state.E); + } } else { @@ -227,6 +248,7 @@ public class Ema /// /// Calculates EMA in-place using alpha, writing results to pre-allocated output span. /// Zero-allocation method for maximum performance. + /// Bias correction continues until compensator decays to 1e-10. /// /// Input values /// Output span (must be same length as source) @@ -256,8 +278,8 @@ public class Ema ema += alpha * (val - ema); e *= oneMinusAlpha; - // Bias correction until warmed up - output[i] = e > 1e-10 ? ema / (1.0 - e) : ema; + // Bias correction until compensator fully decays + output[i] = e > COMPENSATOR_THRESHOLD ? ema / (1.0 - e) : ema; } } diff --git a/lib/averages/sma/Sma.Quantower.cs b/lib/averages/sma/Sma.Quantower.cs index 8548a1e2..5e26082a 100644 --- a/lib/averages/sma/Sma.Quantower.cs +++ b/lib/averages/sma/Sma.Quantower.cs @@ -17,6 +17,7 @@ public class SmaIndicator : Indicator, IWatchlistIndicator private Sma? ma; protected LineSeries? Series; protected string? SourceName; + private int _warmupBarIndex = -1; public int MinHistoryDepths => Period; int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths; @@ -39,6 +40,7 @@ public class SmaIndicator : Indicator, IWatchlistIndicator { ma = new Sma(Period); SourceName = Source.ToString(); + _warmupBarIndex = -1; // Reset warmup tracking when period changes base.OnInit(); } @@ -49,11 +51,16 @@ public class SmaIndicator : Indicator, IWatchlistIndicator TValue result = ma!.Update(input, isNew); Series!.SetValue(result.Value); Series!.SetMarker(0, Color.Transparent); //OnPaintChart draws the line, hidden here + + // Track when IsHot becomes true for the first time + if (_warmupBarIndex < 0 && ma!.IsHot) + _warmupBarIndex = Count; } public override void OnPaintChart(PaintChartEventArgs args) { base.OnPaintChart(args); - this.PaintSmoothCurve(args, Series!, 0, showColdValues: ShowColdValues, tension: 0.2); + int warmupPeriod = _warmupBarIndex > 0 ? _warmupBarIndex : Count; + this.PaintSmoothCurve(args, Series!, warmupPeriod, showColdValues: ShowColdValues, tension: 0.2); } } diff --git a/lib/averages/wma/Wma.Quantower.cs b/lib/averages/wma/Wma.Quantower.cs index c35cd996..69402c40 100644 --- a/lib/averages/wma/Wma.Quantower.cs +++ b/lib/averages/wma/Wma.Quantower.cs @@ -15,6 +15,7 @@ public class WmaIndicator : Indicator, IWatchlistIndicator public bool ShowColdValues { get; set; } = true; private Wma? ma; + private int _warmupBarIndex = -1; protected LineSeries? Series; protected string? SourceName; @@ -38,6 +39,7 @@ public class WmaIndicator : Indicator, IWatchlistIndicator protected override void OnInit() { ma = new Wma(Period); + _warmupBarIndex = -1; SourceName = Source.ToString(); base.OnInit(); } @@ -47,6 +49,8 @@ public class WmaIndicator : Indicator, IWatchlistIndicator TValue input = this.GetInputValue(args, Source); bool isNew = args.Reason == UpdateReason.NewBar || args.Reason == UpdateReason.HistoricalBar; TValue result = ma!.Update(input, isNew); + if (_warmupBarIndex < 0 && ma!.IsHot) + _warmupBarIndex = Count; Series!.SetValue(result.Value); Series!.SetMarker(0, Color.Transparent); //OnPaintChart draws the line, hidden here } @@ -54,6 +58,6 @@ public class WmaIndicator : Indicator, IWatchlistIndicator public override void OnPaintChart(PaintChartEventArgs args) { base.OnPaintChart(args); - this.PaintSmoothCurve(args, Series!, 0, showColdValues: ShowColdValues, tension: 0.2); + this.PaintSmoothCurve(args, Series!, _warmupBarIndex, showColdValues: ShowColdValues, tension: 0.2); } } diff --git a/lib/feeds/gbm/gbm.cs b/lib/feeds/gbm/gbm.cs index 97499df3..730e92fc 100644 --- a/lib/feeds/gbm/gbm.cs +++ b/lib/feeds/gbm/gbm.cs @@ -8,7 +8,7 @@ namespace QuanTAlib; /// Stateless design - only maintains minimal state needed for price continuity. /// [SkipLocalsInit] -#pragma warning disable S101 // Types should be named in PascalCase - GBM is a standard acronym + // Types should be named in PascalCase - GBM is a standard acronym public class GBM : IFeed #pragma warning restore S101 { @@ -190,9 +190,11 @@ public class GBM : IFeed double open = currentPrice; double close = price; +#pragma warning disable S2245 // Random is acceptable for simulation/testing purposes double rnd1 = _rnd.NextDouble(); double rnd2 = _rnd.NextDouble(); double rnd3 = _rnd.NextDouble(); +#pragma warning restore S2245 t[i] = currentTime; o[i] = open; diff --git a/quantower/Directory.Build.props b/quantower/Directory.Build.props index 0144aab6..639b4fcc 100644 --- a/quantower/Directory.Build.props +++ b/quantower/Directory.Build.props @@ -1,4 +1,7 @@ + + + obj\Averages\