Refactor EMA, SMA, and WMA indicators to improve warmup tracking and coverage calculations; enhance tests for IsHot behavior and period dependency; update project files for better structure and maintainability.

This commit is contained in:
Miha Kralj
2025-12-03 13:49:45 -08:00
parent 4a0a8d6da2
commit 4a0435c217
7 changed files with 104 additions and 25 deletions
+8 -1
View File
@@ -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);
}
}
+45 -11
View File
@@ -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]
+32 -10
View File
@@ -28,19 +28,20 @@ public class Ema
private struct State : IEquatable<State>
{
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;
/// <summary>
/// Core EMA calculation kernel.
/// Assumes input has already been validated via GetValidValue().
/// IsHot becomes true at 95% coverage (E &lt;= 0.05).
/// Bias correction continues until compensator decays to 1e-10.
/// </summary>
[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
/// <summary>
/// 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.
/// </summary>
/// <param name="source">Input values</param>
/// <param name="output">Output span (must be same length as source)</param>
@@ -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;
}
}
+8 -1
View File
@@ -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);
}
}
+5 -1
View File
@@ -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);
}
}
+3 -1
View File
@@ -8,7 +8,7 @@ namespace QuanTAlib;
/// Stateless design - only maintains minimal state needed for price continuity.
/// </summary>
[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;